statewire 0.23.0 → 0.24.1

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.
Files changed (43) hide show
  1. package/dist/client/core.d.ts +4 -3
  2. package/dist/client/core.d.ts.map +1 -1
  3. package/dist/client/core.js +56 -45
  4. package/dist/client/core.js.map +1 -1
  5. package/dist/client/session.d.ts +56 -12
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +632 -260
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +1 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js +1 -0
  12. package/dist/client/sessionStore.js.map +1 -1
  13. package/dist/host/host.d.ts +6 -5
  14. package/dist/host/host.d.ts.map +1 -1
  15. package/dist/host/host.js.map +1 -1
  16. package/dist/indexedDb.d.ts +15 -0
  17. package/dist/indexedDb.d.ts.map +1 -0
  18. package/dist/indexedDb.js +171 -0
  19. package/dist/indexedDb.js.map +1 -0
  20. package/dist/statewire.d.ts +117 -59
  21. package/dist/statewire.d.ts.map +1 -1
  22. package/dist/statewire.js +13 -25
  23. package/dist/statewire.js.map +1 -1
  24. package/dist/storage.d.ts +42 -0
  25. package/dist/storage.d.ts.map +1 -1
  26. package/dist/storage.js +387 -125
  27. package/dist/storage.js.map +1 -1
  28. package/dist/transports/remote.d.ts.map +1 -1
  29. package/dist/transports/remote.js +7 -9
  30. package/dist/transports/remote.js.map +1 -1
  31. package/dist/types.d.ts +6 -7
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +2 -1
  35. package/src/client/core.ts +81 -64
  36. package/src/client/session.ts +886 -424
  37. package/src/client/sessionStore.ts +2 -0
  38. package/src/host/host.ts +10 -3
  39. package/src/indexedDb.ts +167 -0
  40. package/src/statewire.ts +133 -86
  41. package/src/storage.ts +548 -112
  42. package/src/transports/remote.ts +13 -14
  43. package/src/types.ts +6 -7
@@ -12,6 +12,7 @@ export const SessionStorageContext = createContext<
12
12
  export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
13
13
  let record: StatewireClient.SessionRecord | null = null;
14
14
  return {
15
+ durable: false,
15
16
  load: () => record,
16
17
  save: (next) => {
17
18
  record = next;
@@ -28,6 +29,7 @@ export const LinkContext = createContext<{
28
29
  canOvertake?:
29
30
  | Readonly<Record<string, StatewireClient.CanOvertake>>
30
31
  | undefined;
32
+ onSettled?: StatewireClient.OnSettled | undefined;
31
33
  }>({});
32
34
 
33
35
  export const DocumentStorageContext = createContext<
package/src/host/host.ts CHANGED
@@ -151,7 +151,9 @@ export type CommandsInvocation = {
151
151
  readonly id?: string;
152
152
  readonly clientId?: string;
153
153
  /** Present while the queued command is held. */
154
- readonly hold?: { meta?: unknown };
154
+ readonly held?: true;
155
+ /** Client-only data persisted with an outbox entry. */
156
+ readonly localMeta?: unknown;
155
157
  };
156
158
 
157
159
  export type CommandsOutcome =
@@ -161,12 +163,17 @@ export type CommandsOutcome =
161
163
  export type CommandsListener = {
162
164
  onStart: (inv: CommandsInvocation) => void;
163
165
  onPosted?: (inv: CommandsInvocation) => void;
164
- /** The invocation's seq or hold changed in place. */
166
+ /** The invocation's seq, hold or local meta changed in place. */
165
167
  onChange?: (inv: CommandsInvocation) => void;
166
168
  onApplied: (inv: CommandsInvocation) => void;
167
169
  /** Canonical state now covers the invocation's effect — drop any overlay. */
168
170
  onCovered?: (inv: CommandsInvocation) => void;
169
- onSettle: (inv: CommandsInvocation, outcome: CommandsOutcome) => void;
171
+ /** `durable`: the session persisted the settled update and delivers it itself. */
172
+ onSettle: (
173
+ inv: CommandsInvocation,
174
+ outcome: CommandsOutcome,
175
+ durable?: boolean,
176
+ ) => void;
170
177
  };
171
178
 
172
179
  export type CommandsSubmission = {
@@ -0,0 +1,167 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { resource, useResource } from "@assistant-ui/tap";
3
+ import { KeyValueSessionStorage } from "./storage";
4
+ import type { StatewireClient } from "./statewire";
5
+ import { randomId } from "./util/randomId";
6
+
7
+ const STORE = "sessions";
8
+
9
+ const request = <T>(req: IDBRequest<T>) =>
10
+ new Promise<T>((resolve, reject) => {
11
+ req.onsuccess = () => resolve(req.result);
12
+ req.onerror = () => reject(req.error);
13
+ });
14
+
15
+ const backends = new Map<string, KeyValueSessionStorage.Backend>();
16
+
17
+ /** A key-value backend over one IndexedDB database; writes broadcast to other runtimes. */
18
+ export const indexedDbBackend = (
19
+ database: string,
20
+ ): KeyValueSessionStorage.Backend => {
21
+ const existing = backends.get(database);
22
+ if (existing) return existing;
23
+ if (typeof indexedDB === "undefined")
24
+ throw new Error("statewire: IndexedDbSession requires indexedDB");
25
+ let opened: Promise<IDBDatabase> | undefined;
26
+ const db = () =>
27
+ (opened ??= (() => {
28
+ const open = indexedDB.open(database, 1);
29
+ open.onupgradeneeded = () => open.result.createObjectStore(STORE);
30
+ return request(open);
31
+ })());
32
+ // Writes resolve once their transaction commits.
33
+ const run = async <T>(
34
+ mode: IDBTransactionMode,
35
+ fn: (store: IDBObjectStore) => IDBRequest<T>,
36
+ ) => {
37
+ const tx = (await db()).transaction(STORE, mode);
38
+ const committed =
39
+ mode === "readonly"
40
+ ? undefined
41
+ : new Promise<void>((resolve, reject) => {
42
+ tx.oncomplete = () => resolve();
43
+ tx.onerror = () => reject(tx.error);
44
+ tx.onabort = () => reject(tx.error);
45
+ });
46
+ const result = await request(fn(tx.objectStore(STORE)));
47
+ await committed;
48
+ return result;
49
+ };
50
+ const channel = new BroadcastChannel(`statewire-idb:${database}`);
51
+ const announce = (key: string) => channel.postMessage(key);
52
+ const backend: KeyValueSessionStorage.Backend = {
53
+ getItem: async (key) =>
54
+ ((await run("readonly", (s) => s.get(key))) as string | undefined) ??
55
+ null,
56
+ setItem: async (key, value) => {
57
+ await run("readwrite", (s) => s.put(value, key));
58
+ announce(key);
59
+ },
60
+ removeItem: async (key) => {
61
+ await run("readwrite", (s) => s.delete(key));
62
+ announce(key);
63
+ },
64
+ getAllKeys: async () =>
65
+ (await run("readonly", (s) => s.getAllKeys())) as string[],
66
+ withUnused: (key, action) =>
67
+ navigator.locks.request(
68
+ `statewire-active:${database}:${key}`,
69
+ { ifAvailable: true },
70
+ (lock) =>
71
+ lock === null
72
+ ? undefined
73
+ : navigator.locks.request(
74
+ `statewire-session:${database}:${key}`,
75
+ { ifAvailable: true },
76
+ (inner) => (inner === null ? undefined : action()),
77
+ ),
78
+ ),
79
+ subscribe: (listener) => {
80
+ const own = new BroadcastChannel(`statewire-idb:${database}`);
81
+ own.onmessage = (event: MessageEvent<string>) => listener(event.data);
82
+ return () => own.close();
83
+ },
84
+ };
85
+ backends.set(database, backend);
86
+ return backend;
87
+ };
88
+
89
+ const holdLock = (name: string, mode: LockMode = "exclusive") => {
90
+ let release: (() => void) | undefined;
91
+ let disposed = false;
92
+ void navigator.locks
93
+ .request(name, { mode }, () => {
94
+ if (disposed) return;
95
+ return new Promise<void>((resolve) => {
96
+ release = resolve;
97
+ });
98
+ })
99
+ .catch(() => {});
100
+ return () => {
101
+ disposed = true;
102
+ release?.();
103
+ };
104
+ };
105
+
106
+ const useIndexedDbSession = ({
107
+ database = "statewire",
108
+ key,
109
+ namespace = "statewire-session:",
110
+ ...options
111
+ }: IndexedDbSession.Options): StatewireClient.SessionStorage => {
112
+ if (typeof navigator === "undefined" || navigator.locks === undefined)
113
+ throw new Error(
114
+ "statewire: IndexedDbSession requires the Web Locks API (navigator.locks)",
115
+ );
116
+ const backend = indexedDbBackend(database);
117
+ const storageKey = namespace + key;
118
+ const [tabId] = useState(randomId);
119
+ useEffect(() => holdLock(`statewire-tab:${tabId}`), [tabId]);
120
+ useEffect(
121
+ () => holdLock(`statewire-active:${database}:${storageKey}`, "shared"),
122
+ [database, storageKey],
123
+ );
124
+ const storage = useResource(
125
+ KeyValueSessionStorage({ ...options, backend, key, namespace }),
126
+ );
127
+ return useMemo(
128
+ (): StatewireClient.SessionStorage => ({
129
+ ...storage,
130
+ lock: (fn) =>
131
+ navigator.locks.request(
132
+ `statewire-session:${database}:${storageKey}`,
133
+ async () => fn(),
134
+ ),
135
+ notifyLock: (fn) =>
136
+ navigator.locks.request(
137
+ `statewire-notify:${database}:${storageKey}`,
138
+ fn,
139
+ ),
140
+ owner: {
141
+ id: tabId,
142
+ whenGone: (id, signal) =>
143
+ navigator.locks.request(`statewire-tab:${id}`, { signal }, () => {}),
144
+ },
145
+ subscribe: (listener) =>
146
+ backend.subscribe!((changed) => {
147
+ if (changed === storageKey) listener();
148
+ }),
149
+ }),
150
+ [storage, backend, database, storageKey, tabId],
151
+ );
152
+ };
153
+
154
+ /** Shared session storage on IndexedDB: sharers serialize through Web Locks and hear each other over a BroadcastChannel. */
155
+ export const IndexedDbSession = resource(useIndexedDbSession);
156
+
157
+ export namespace IndexedDbSession {
158
+ export type Options = Omit<
159
+ KeyValueSessionStorage.Options,
160
+ "backend" | "namespace"
161
+ > & {
162
+ /** IndexedDB database name; default "statewire". */
163
+ database?: string;
164
+ /** Default "statewire-session:". */
165
+ namespace?: string;
166
+ };
167
+ }
package/src/statewire.ts CHANGED
@@ -46,6 +46,7 @@ const useSessionTransport = ({
46
46
  canOvertake?:
47
47
  | Readonly<Record<string, StatewireClient.CanOvertake>>
48
48
  | undefined;
49
+ onSettled?: StatewireClient.OnSettled | undefined;
49
50
  };
50
51
  }): Statewire<unknown> =>
51
52
  useContextProvider(SessionStorageContext, session, () =>
@@ -97,6 +98,8 @@ export const useStatewire = <
97
98
  keyPrefixes: options.keyPrefixes,
98
99
  }),
99
100
  };
101
+ const handlesUpdates = options.onCommandChange !== undefined;
102
+ const coreRef = useRef<ReturnType<typeof useStatewireCore> | null>(null);
100
103
  const link = useMemo(
101
104
  () => ({
102
105
  ...(options.isNew === undefined ? {} : { isNew: options.isNew }),
@@ -104,8 +107,14 @@ export const useStatewire = <
104
107
  ...(options.canOvertake === undefined
105
108
  ? {}
106
109
  : { canOvertake: options.canOvertake }),
110
+ ...(handlesUpdates && {
111
+ onSettled: (update: Statewire.CommandUpdate) =>
112
+ latest.current.onCommandChange?.(update as never, {
113
+ state: coreRef.current?.getState() as never,
114
+ }),
115
+ }),
107
116
  }),
108
- [options.isNew, options.context, options.canOvertake],
117
+ [options.isNew, options.context, options.canOvertake, handlesUpdates],
109
118
  );
110
119
  const value = useResource(
111
120
  SessionTransport({
@@ -125,6 +134,7 @@ export const useStatewire = <
125
134
  localContext(protocol, context);
126
135
  }, [options.context, localContext]);
127
136
  const core = useStatewireCore(() => latest.current, value);
137
+ coreRef.current = core;
128
138
  useEffect(() => core.rematerialize(), [core, options.materializeKey]);
129
139
  const [subscribe] = useState(
130
140
  () => (listener: () => void) =>
@@ -137,11 +147,6 @@ export const useStatewire = <
137
147
  core.getState,
138
148
  ) as TState;
139
149
  const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
140
- const failed = useSyncExternalStore(
141
- subscribe,
142
- core.getFailed,
143
- core.getFailed,
144
- );
145
150
  const outboxEntries = useSyncExternalStore(
146
151
  subscribe,
147
152
  core.getOutbox,
@@ -153,7 +158,6 @@ export const useStatewire = <
153
158
  selection,
154
159
  invoke: transportInvoke,
155
160
  setContext: transportSetContext,
156
- failures: transportFailures,
157
161
  outbox: transportOutbox,
158
162
  } = value;
159
163
 
@@ -183,12 +187,6 @@ export const useStatewire = <
183
187
  );
184
188
 
185
189
  return useMemo(() => {
186
- const failures = (): NonNullable<typeof transportFailures> => {
187
- if (!transportFailures) {
188
- throw new Error("statewire: this transport has no failed-send lane");
189
- }
190
- return transportFailures;
191
- };
192
190
  const outbox = (): NonNullable<typeof transportOutbox> => {
193
191
  if (!transportOutbox) {
194
192
  throw new Error("statewire: this transport has no outbox");
@@ -205,16 +203,13 @@ export const useStatewire = <
205
203
  connection,
206
204
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
207
205
  isBusy,
208
- failed,
209
- retryFailed: (entry: StatewireClient.FailedCommand) =>
210
- failures().retry(entry),
211
- discardFailed: (entry: StatewireClient.FailedCommand) =>
212
- failures().discard(entry),
213
206
  outbox: {
214
207
  entries: outboxEntries,
215
- hold: (entry, meta) => outbox().hold(entry, meta),
216
- release: (entry) => outbox().release(entry),
217
- remove: (entry) => outbox().remove(entry),
208
+ hold: (id) => outbox().hold(id),
209
+ release: (id) => outbox().release(id),
210
+ remove: (id) => outbox().remove(id),
211
+ update: (id, fn) => outbox().update(id, fn),
212
+ insert: (entry) => outbox().insert(entry),
218
213
  },
219
214
  };
220
215
  }, [
@@ -222,13 +217,11 @@ export const useStatewire = <
222
217
  transportOutbox,
223
218
  state,
224
219
  isBusy,
225
- failed,
226
220
  connection,
227
221
  core,
228
222
  source,
229
223
  selection,
230
224
  invoke,
231
- transportFailures,
232
225
  setContext,
233
226
  ]);
234
227
  };
@@ -286,18 +279,6 @@ export class StatewireClient<
286
279
  return this.root.getValue().isBusy;
287
280
  }
288
281
 
289
- get failed(): readonly StatewireClient.FailedCommand[] {
290
- return this.root.getValue().failed;
291
- }
292
-
293
- retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {
294
- return this.root.getValue().retryFailed(entry);
295
- }
296
-
297
- discardFailed(entry: StatewireClient.FailedCommand): void {
298
- this.root.getValue().discardFailed(entry);
299
- }
300
-
301
282
  get outbox(): StatewireClient.Outbox {
302
283
  return this.root.getValue().outbox;
303
284
  }
@@ -345,17 +326,19 @@ export namespace StatewireClient {
345
326
  state: NonNullable<TState>,
346
327
  ctx: { changes: Statewire.Changes },
347
328
  ) => void;
329
+ /**
330
+ * queued, posted and applied updates are best-effort and in memory. A
331
+ * durable session persists each `settled` update before the command leaves
332
+ * the record and delivers it to one sharer at a time: at-least-once under
333
+ * the same `id`, acked once the returned promise resolves; a pending
334
+ * delivery blocks later ones for the same session.
335
+ */
348
336
  onCommandChange?: (
349
337
  update: Statewire.CommandUpdate<TCommands>,
350
338
  ctx: { state: NonNullable<TState> },
351
- ) => void;
339
+ ) => unknown;
352
340
  onConnectionChange?: (connection: Connection) => void;
353
341
  onError?: (error: ConnectionError) => void;
354
- /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
355
- failureCovered?: (
356
- state: NonNullable<TState>,
357
- entry: FailedCommand,
358
- ) => boolean;
359
342
  };
360
343
 
361
344
  export type Helpers<
@@ -379,25 +362,40 @@ export namespace StatewireClient {
379
362
  commands: Statewire.CommandsProxy<TCommands>;
380
363
  /** Negation of the server's broadcast idle level (a transport without idle is always busy). */
381
364
  isBusy: boolean;
382
- /** Non-accepted sends retained in the session record; reload with it. */
383
- failed: readonly FailedCommand[];
384
- /** Re-enqueues the entry's command on fresh clocks and removes the entry. */
385
- retryFailed: (entry: FailedCommand) => Promise<unknown>;
386
- /** Removes the entry. */
387
- discardFailed: (entry: FailedCommand) => void;
388
- /** Commands queued without a seq yet; a transport without a session has an empty outbox whose methods throw. */
365
+ /** Unsettled commands in queue order; a transport without a session has an empty outbox whose methods throw. */
389
366
  outbox: Outbox;
390
367
  };
391
368
 
392
- /** The queued commands no sharer has stamped yet, and the operations on them. */
369
+ /** The unsettled commands across sharers, and the operations on them; every verb throws `outbox: no entry <id>` or `outbox: <id> is in flight`. */
393
370
  export type Outbox = {
394
371
  readonly entries: readonly OutboxEntry[];
395
- /** Keeps the entry out of every flush until released; `meta` persists with it. */
396
- hold: (entry: OutboxEntry, meta?: unknown) => Promise<"held" | "stamped">;
397
- /** Lets the next flush stamp the entry. */
398
- release: (entry: OutboxEntry) => Promise<"released" | "stamped">;
399
- /** Drops the entry; its send rejects `{ fate: "aborted", cause: "removed" }`. */
400
- remove: (entry: OutboxEntry) => Promise<"removed" | "stamped">;
372
+ /** queued → held: no flush stamps it until released. */
373
+ hold: (id: string) => void;
374
+ /** held → queued; settles with the command's outcome. */
375
+ release: (id: string) => Promise<unknown>;
376
+ /** Drops a queued or held entry; its pending send rejects `{ fate: "aborted", cause: "removed" }`. */
377
+ remove: (id: string) => void;
378
+ /** Patches the entry under the storage lock; `fn` gets the current entry. */
379
+ update: (id: string, fn: (entry: OutboxEntry) => OutboxPatch) => void;
380
+ /** Adds a command no send awaits, at the lane's tail or next to `after`/`before`; returns its id. */
381
+ insert: (entry: OutboxInsert) => string;
382
+ };
383
+
384
+ export type OutboxPatch = {
385
+ held?: boolean;
386
+ localMeta?: unknown;
387
+ command?: { params: readonly unknown[] };
388
+ };
389
+
390
+ export type OutboxInsert = {
391
+ /** The `settled` update this re-inserts: the insert acks it in the same write, so a redelivery never inserts twice. */
392
+ from?: string;
393
+ protocol: string;
394
+ command: { method: string; params: readonly unknown[] };
395
+ held?: boolean;
396
+ localMeta?: unknown;
397
+ after?: string;
398
+ before?: string;
401
399
  };
402
400
 
403
401
  /** Connection lifecycle and whether its current state warrants a problem indicator. */
@@ -448,17 +446,20 @@ export namespace StatewireClient {
448
446
  ) => void;
449
447
 
450
448
  export type OptimisticContext = {
449
+ /** Enqueue time. */
451
450
  ts: number;
452
451
  /** Absent while the command waits in the outbox. */
453
452
  seq?: number;
454
453
  clientId?: string;
455
- /** The outbox hold, with its meta; null when not held. */
456
- held: null | { meta: unknown };
454
+ state: OutboxState;
455
+ localMeta: unknown;
457
456
  };
458
457
 
459
458
  export type InvokeOptions = {
460
- /** Enqueue the command already held, with the hold's meta. */
461
- hold?: { meta?: unknown };
459
+ /** Enqueue the command held. */
460
+ hold?: boolean;
461
+ /** Client-only data persisted with the entry, never sent. */
462
+ localMeta?: unknown;
462
463
  };
463
464
 
464
465
  /**
@@ -469,7 +470,7 @@ export namespace StatewireClient {
469
470
  */
470
471
  export type FlushReason = "enqueue" | "reconnect" | "reload" | "release";
471
472
 
472
- /** Runs in the sharer about to stamp, under the storage lock, once per reason; `outbox.hold` and `outbox.remove` on these entries apply synchronously. */
473
+ /** Runs in the sharer about to stamp, under the storage lock, once per reason; outbox verbs on these entries apply synchronously before stamping. */
473
474
  export type OnFlush = (flush: {
474
475
  commands: readonly OutboxEntry[];
475
476
  reason: FlushReason;
@@ -508,7 +509,7 @@ export namespace StatewireClient {
508
509
  | { fate: "expired"; cause: "server" }
509
510
  | {
510
511
  fate: "aborted";
511
- cause: "unmount" | "stream-gone" | "session-lost" | "removed";
512
+ cause: "unmount" | "stream-gone" | "session-lost" | "removed" | "quota";
512
513
  predecessorLost?: true;
513
514
  }
514
515
  | {
@@ -516,10 +517,15 @@ export namespace StatewireClient {
516
517
  cause: "unmount" | "stream-gone" | "session-lost";
517
518
  };
518
519
 
519
- /** Held commands a proven record gap settled `{fate: "lost", cause: "session-lost"}`; return "requeue" to re-enqueue them on the reset clocks. */
520
+ /**
521
+ * Temporary and discouraged; removed in a later release. A proven record gap
522
+ * settles the lane's commands `{ fate: "lost", cause: "session-lost" }` and
523
+ * drops its outbox. Returning "requeue" instead re-enqueues this client's
524
+ * lost stamps (fresh ids, at the tail) and keeps the outbox.
525
+ */
520
526
  export type OnSessionLost = (
521
527
  pending: ReadonlyArray<{
522
- id?: string;
528
+ id: string;
523
529
  method: string;
524
530
  params: readonly unknown[];
525
531
  ts: number;
@@ -528,19 +534,18 @@ export namespace StatewireClient {
528
534
  }>,
529
535
  ) => "requeue" | "drop";
530
536
 
531
- /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
532
- export type FailedCommand = {
533
- readonly protocol: string;
534
- readonly produced?: SessionCommand["produced"];
535
- readonly command: string;
536
- readonly params: readonly unknown[];
537
- readonly meta?: unknown;
538
- readonly failure: SendFailure;
539
- readonly ts: number;
540
- readonly seq: number;
541
- };
537
+ /** Reports a deprecated code path firing, so its use can be measured before removal. */
538
+ export type OnDeprecatedPath = (event: {
539
+ kind: "session-lost-requeue";
540
+ count: number;
541
+ }) => void;
542
+
543
+ /** Delivers one durable `settled` update; acked once the returned promise resolves. */
544
+ export type OnSettled = (update: Statewire.CommandUpdate) => unknown;
542
545
 
543
- /** A queued command without a seq, as persisted. */
546
+ export type OutboxState = "queued" | "held" | "sending";
547
+
548
+ /** A queued command as persisted. */
544
549
  export type OutboxCommand = {
545
550
  id: string;
546
551
  produced?: {
@@ -549,30 +554,41 @@ export namespace StatewireClient {
549
554
  };
550
555
  method: string;
551
556
  params: unknown[];
557
+ /** Encoding of non-JSON params. */
552
558
  meta?: unknown;
559
+ /** Enqueue time. */
553
560
  ts: number;
554
561
  /** Present while held. */
555
- hold?: { meta?: unknown };
562
+ held?: true;
563
+ localMeta?: unknown;
556
564
  /** Its hold was released; the next flush reports reason "release". */
557
565
  released?: true;
558
566
  /** The enqueuing sharer's `SessionStorage.owner.id` (shared storage only). */
559
567
  by?: string;
560
568
  };
561
569
 
562
- /** A queued command without a seq, in queue order. */
570
+ /** An unsettled command, in queue order. */
563
571
  export type OutboxEntry = {
564
572
  readonly id: string;
565
573
  readonly protocol: string;
566
- readonly command: { method: string; params: readonly unknown[] };
567
- /** The hold, with its meta; null when not held. */
568
- readonly held: null | { readonly meta: unknown };
574
+ readonly command: {
575
+ readonly method: string;
576
+ readonly params: readonly unknown[];
577
+ };
578
+ /** Enqueue time. */
579
+ readonly ts: number;
580
+ readonly state: OutboxState;
581
+ /** Client-only, persisted, never sent. */
582
+ readonly localMeta: unknown;
583
+ /** False when the entry lives in page memory only. */
584
+ readonly durable: boolean;
569
585
  };
570
586
 
571
- /** Whether `entry` may move ahead of `ahead`, a held entry or one waiting behind a hold. */
587
+ /** Whether `entry` may move ahead of `ahead`, a held entry or one waiting behind one. */
572
588
  export type CanOvertake = (entry: OutboxEntry, ahead: OutboxEntry) => boolean;
573
589
 
574
590
  /** A stamped command as persisted while un-durable: its lane seq, the sharer that stamped it, plus the wire statement. */
575
- export type SessionCommand = Omit<OutboxCommand, "hold" | "released"> & {
591
+ export type SessionCommand = Omit<OutboxCommand, "held" | "released"> & {
576
592
  seq: number;
577
593
  /** The stamping sharer's `SessionStorage.owner.id` (shared storage only). */
578
594
  owner?: string;
@@ -583,12 +599,26 @@ export namespace StatewireClient {
583
599
  nextSeq: number;
584
600
  commands: readonly SessionCommand[];
585
601
  outbox?: readonly OutboxCommand[];
586
- /** Entries another sharer removed, kept until their enqueuer (`by`) rejects them or is gone (shared storage only). */
587
- removed?: readonly { id: string; by: string }[];
602
+ /** Entries another sharer settled unsent, kept until their enqueuer (`by`) rejects them or is gone (shared storage only); no failure means removed. */
603
+ removed?: readonly { id: string; by: string; failure?: SendFailure }[];
588
604
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
589
605
  verdicts?: readonly SessionVerdict[];
590
- /** Non-accepted sends retained for the user; survive record rebases and resets. */
591
- failed?: readonly FailedCommand[];
606
+ /** Terminal `settled` updates awaiting their `onCommandChange` ack. */
607
+ inbox?: readonly StoredSettled[];
608
+ };
609
+
610
+ /** A terminal update as persisted: the stored command plus its outcome. */
611
+ export type StoredSettled = {
612
+ id: string;
613
+ method: string;
614
+ params: unknown[];
615
+ meta?: unknown;
616
+ ts: number;
617
+ seq?: number;
618
+ localMeta?: unknown;
619
+ /** The accepted result, encoded as a one-element param list. */
620
+ result?: { value: unknown[]; meta?: unknown };
621
+ failure?: SendFailure;
592
622
  };
593
623
 
594
624
  /** The persisted session: the client identity plus one lane per durable protocol. */
@@ -611,6 +641,10 @@ export namespace StatewireClient {
611
641
 
612
642
  /** Session storage seam with optional asynchronous hydration and persistence. */
613
643
  export type SessionStorage = {
644
+ /** False when the record lives in page memory only. */
645
+ durable?: boolean;
646
+ /** Re-reads the stored record; awaited under `lock` when present. */
647
+ read?: () => Promise<SessionRecord | null>;
614
648
  /** Resolves when a synchronous load can return the hydrated record. */
615
649
  ready?: Promise<void>;
616
650
  /** Resolves after all queued saves are persisted; awaited before sending statements. */
@@ -631,6 +665,17 @@ export namespace StatewireClient {
631
665
  id: string;
632
666
  whenGone: (id: string, signal: AbortSignal) => Promise<void>;
633
667
  };
668
+ /** Exclusive across sharers while a durable `settled` update is delivered; required alongside `lock`. */
669
+ notifyLock?: <T>(fn: () => Promise<T>) => Promise<T>;
670
+ /** Reports failure notices the session dropped. */
671
+ onEvict?: (event: Omit<EvictEvent, "key">) => void;
672
+ };
673
+
674
+ export type EvictEvent = {
675
+ key: string;
676
+ reason: "expired" | "overflow" | "quota" | "inbox";
677
+ /** The undelivered `settled` updates that went with it. */
678
+ dropped: readonly Statewire.CommandUpdate[];
634
679
  };
635
680
 
636
681
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
@@ -648,6 +693,7 @@ export namespace StatewireClient {
648
693
  headers?: HeadersOption;
649
694
  fetch?: typeof globalThis.fetch;
650
695
  onSessionLost?: OnSessionLost;
696
+ onDeprecatedPath?: OnDeprecatedPath;
651
697
  onFlush?: OnFlush;
652
698
  /** Per protocol: lets an outbox entry move ahead of held or waiting ones; default never. */
653
699
  canOvertake?: Readonly<Record<string, CanOvertake>>;
@@ -669,6 +715,7 @@ export namespace StatewireClient {
669
715
  | (() => Record<string, string> | Promise<Record<string, string>>);
670
716
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
671
717
  onSessionLost?: OnSessionLost;
718
+ onDeprecatedPath?: OnDeprecatedPath;
672
719
  onFlush?: OnFlush;
673
720
  /** Per protocol: lets an outbox entry move ahead of held or waiting ones; default never. */
674
721
  canOvertake?: Readonly<Record<string, CanOvertake>>;