statewire 0.12.0 → 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.
@@ -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
- if (stateChanged || connectionChanged || busyChanged) {
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) {
@@ -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");
package/src/host/draft.ts CHANGED
@@ -30,6 +30,16 @@ const invalid = (path: StatewirePath, message: string) =>
30
30
 
31
31
  type Container = Record<string | number, unknown>;
32
32
 
33
+ // Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
34
+ const codePointLength = (text: string): number => {
35
+ let count = 0;
36
+ for (let index = 0; index < text.length; index += 1) {
37
+ if (text.codePointAt(index)! > 0xffff) index += 1;
38
+ count += 1;
39
+ }
40
+ return count;
41
+ };
42
+
33
43
  type Node = {
34
44
  parent: Node | null;
35
45
  key: string | number;
@@ -96,7 +106,11 @@ export const createStateDraft = <T>(host: DraftHost): StateDraft<T> => {
96
106
  if (suffix === "") return;
97
107
  prepareForWrite(node)[key] = existing + suffix;
98
108
  host.record(
99
- { op: "add", path: [...node.path, key, existing.length], value: suffix },
109
+ {
110
+ op: "add",
111
+ path: [...node.path, key, codePointLength(existing)],
112
+ value: suffix,
113
+ },
100
114
  changedPathFor(node, key),
101
115
  );
102
116
  };
package/src/statewire.ts CHANGED
@@ -59,10 +59,21 @@ export const useStatewire = <
59
59
  core.getState,
60
60
  ) as TState;
61
61
  const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
62
+ const failed = useSyncExternalStore(
63
+ subscribe,
64
+ core.getFailed,
65
+ core.getFailed,
66
+ );
62
67
  const connection = value.connection ?? LIVE_CONNECTION;
63
68
 
64
- return useMemo(
65
- () => ({
69
+ return useMemo(() => {
70
+ const failures = (): NonNullable<typeof value.failures> => {
71
+ if (!value.failures) {
72
+ throw new Error("statewire: this transport has no failed-send lane");
73
+ }
74
+ return value.failures;
75
+ };
76
+ return {
66
77
  state,
67
78
  getState: core.getState as () => TState,
68
79
  connection,
@@ -76,9 +87,13 @@ export const useStatewire = <
76
87
  }
77
88
  value.setContext(contextValue);
78
89
  },
79
- }),
80
- [state, isBusy, connection, core, value],
81
- );
90
+ failed,
91
+ retryFailed: (entry: StatewireClient.FailedCommand) =>
92
+ failures().retry(entry),
93
+ discardFailed: (entry: StatewireClient.FailedCommand) =>
94
+ failures().discard(entry),
95
+ };
96
+ }, [state, isBusy, failed, connection, core, value]);
82
97
  };
83
98
 
84
99
  export const StatewireResource = <
@@ -121,6 +136,18 @@ export class StatewireClient<
121
136
  this.root.getValue().setContext(value);
122
137
  }
123
138
 
139
+ get failed(): readonly StatewireClient.FailedCommand[] {
140
+ return this.root.getValue().failed;
141
+ }
142
+
143
+ retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {
144
+ return this.root.getValue().retryFailed(entry);
145
+ }
146
+
147
+ discardFailed(entry: StatewireClient.FailedCommand): void {
148
+ this.root.getValue().discardFailed(entry);
149
+ }
150
+
124
151
  subscribe(listener: () => void): () => void {
125
152
  return this.root.subscribe(listener);
126
153
  }
@@ -155,6 +182,11 @@ export namespace StatewireClient {
155
182
  ctx: { state: NonNullable<TState> },
156
183
  ) => void;
157
184
  onConnectionChange?: (connection: Connection) => void;
185
+ /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
186
+ failureCovered?: (
187
+ state: NonNullable<TState>,
188
+ entry: FailedCommand,
189
+ ) => boolean;
158
190
  };
159
191
 
160
192
  export type Helpers<
@@ -171,6 +203,12 @@ export namespace StatewireClient {
171
203
  isBusy: boolean;
172
204
  /** Replace this client's ambient context on the server; throws on transports without a client context channel. */
173
205
  setContext: (value: Record<string, unknown>) => void;
206
+ /** Non-accepted sends retained in the session record; reload with it. */
207
+ failed: readonly FailedCommand[];
208
+ /** Re-enqueues the entry's command on fresh clocks and removes the entry. */
209
+ retryFailed: (entry: FailedCommand) => Promise<unknown>;
210
+ /** Removes the entry. */
211
+ discardFailed: (entry: FailedCommand) => void;
174
212
  };
175
213
 
176
214
  export type ConnectionState =
@@ -259,6 +297,16 @@ export namespace StatewireClient {
259
297
  }>,
260
298
  ) => "requeue" | "drop";
261
299
 
300
+ /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
301
+ export type FailedCommand = {
302
+ readonly command: string;
303
+ readonly params: readonly unknown[];
304
+ readonly meta?: unknown;
305
+ readonly failure: SendFailure;
306
+ readonly ts: number;
307
+ readonly seq: number;
308
+ };
309
+
262
310
  /** A command as persisted while un-durable: its lane seq plus the wire statement. */
263
311
  export type SessionCommand = {
264
312
  seq: number;
@@ -275,6 +323,8 @@ export namespace StatewireClient {
275
323
  commands: readonly SessionCommand[];
276
324
  /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
277
325
  verdicts?: readonly SessionVerdict[];
326
+ /** Non-accepted sends retained for the user; survive record rebases and resets. */
327
+ failed?: readonly FailedCommand[];
278
328
  };
279
329
 
280
330
  /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
@@ -1,6 +1,9 @@
1
1
  import { useEffect } from "react";
2
2
  import type { Statewire } from "../types";
3
3
  import {
4
+ discardFailed,
5
+ failedCommands,
6
+ retryFailed,
4
7
  setContext,
5
8
  setTransport,
6
9
  useStatewireSession,
@@ -39,5 +42,10 @@ export const useStatewireRemote = <
39
42
  return !s.idle;
40
43
  },
41
44
  setContext: (value) => setContext(s, value),
45
+ failures: {
46
+ list: () => failedCommands(s),
47
+ retry: (entry) => retryFailed(s, entry),
48
+ discard: (entry) => discardFailed(s, entry),
49
+ },
42
50
  };
43
51
  };
package/src/types.ts CHANGED
@@ -59,6 +59,12 @@ export interface Statewire<
59
59
  isBusy?: boolean;
60
60
  /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
61
61
  setContext?: (value: Record<string, unknown>) => void;
62
+ /** The retained failed-send lane; present on transports with a durable session. */
63
+ failures?: {
64
+ list: () => readonly StatewireClient.FailedCommand[];
65
+ retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;
66
+ discard: (entry: StatewireClient.FailedCommand) => void;
67
+ };
62
68
  }
63
69
 
64
70
  const describe = (failure: StatewireClient.SendFailure): string => {
package/src/wire/ops.ts CHANGED
@@ -64,15 +64,42 @@ export const sanitizeValue = (value: unknown, parent?: string): unknown =>
64
64
  const invalid = (op: Statewire.Op) =>
65
65
  new Error(`Statewire cannot apply ${op.op} at path: ${op.path.join(".")}`);
66
66
 
67
+ // Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
68
+ const utf16Index = (text: string, offset: number): number => {
69
+ let index = 0;
70
+ for (let remaining = offset; remaining > 0; remaining -= 1) {
71
+ if (index >= text.length) return -1;
72
+ index += text.codePointAt(index)! > 0xffff ? 2 : 1;
73
+ }
74
+ return index;
75
+ };
76
+
77
+ const countCodePoints = (text: string): number => {
78
+ let count = 0;
79
+ for (let index = 0; index < text.length; index += 1) {
80
+ if (text.codePointAt(index)! > 0xffff) index += 1;
81
+ count += 1;
82
+ }
83
+ return count;
84
+ };
85
+
86
+ let last: { str: string; count: number } | null = null;
87
+
67
88
  const insertedText = (
68
89
  current: string,
69
90
  op: Extract<Statewire.Op, { op: "add" }>,
70
91
  offset: number,
71
92
  ): string => {
72
- if (typeof op.value !== "string" || offset > current.length) {
73
- throw invalid(op);
93
+ if (typeof op.value !== "string") throw invalid(op);
94
+ const memoized =
95
+ last !== null && last.str === current && last.count === offset;
96
+ const index = memoized ? current.length : utf16Index(current, offset);
97
+ if (index < 0) throw invalid(op);
98
+ const next = current.slice(0, index) + op.value + current.slice(index);
99
+ if (index === current.length) {
100
+ last = { str: next, count: offset + countCodePoints(op.value) };
74
101
  }
75
- return current.slice(0, offset) + op.value + current.slice(offset);
102
+ return next;
76
103
  };
77
104
 
78
105
  const applyAdd = (