statewire 0.0.0-0 → 0.0.2

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 (154) hide show
  1. package/README.md +84 -1
  2. package/dist/client/changes.d.ts +7 -0
  3. package/dist/client/changes.d.ts.map +1 -0
  4. package/dist/client/changes.js +25 -0
  5. package/dist/client/changes.js.map +1 -0
  6. package/dist/client/connection.d.ts +63 -0
  7. package/dist/client/connection.d.ts.map +1 -0
  8. package/dist/client/connection.js +211 -0
  9. package/dist/client/connection.js.map +1 -0
  10. package/dist/client/core.d.ts +14 -0
  11. package/dist/client/core.d.ts.map +1 -0
  12. package/dist/client/core.js +272 -0
  13. package/dist/client/core.js.map +1 -0
  14. package/dist/client/session.d.ts +71 -0
  15. package/dist/client/session.d.ts.map +1 -0
  16. package/dist/client/session.js +529 -0
  17. package/dist/client/session.js.map +1 -0
  18. package/dist/client/sessionStore.d.ts +16 -0
  19. package/dist/client/sessionStore.d.ts.map +1 -0
  20. package/dist/client/sessionStore.js +14 -0
  21. package/dist/client/sessionStore.js.map +1 -0
  22. package/dist/client/timing.d.ts +11 -0
  23. package/dist/client/timing.d.ts.map +1 -0
  24. package/dist/client/timing.js +15 -0
  25. package/dist/client/timing.js.map +1 -0
  26. package/dist/host/adapter.d.ts +8 -0
  27. package/dist/host/adapter.d.ts.map +1 -0
  28. package/dist/host/adapter.js +23 -0
  29. package/dist/host/adapter.js.map +1 -0
  30. package/dist/host/draft.d.ts +28 -0
  31. package/dist/host/draft.d.ts.map +1 -0
  32. package/dist/host/draft.js +211 -0
  33. package/dist/host/draft.js.map +1 -0
  34. package/dist/host/engine.d.ts +14 -0
  35. package/dist/host/engine.d.ts.map +1 -0
  36. package/dist/host/engine.js +17 -0
  37. package/dist/host/engine.js.map +1 -0
  38. package/dist/host/host.d.ts +62 -0
  39. package/dist/host/host.d.ts.map +1 -0
  40. package/dist/host/host.js +331 -0
  41. package/dist/host/host.js.map +1 -0
  42. package/dist/host/socket.d.ts +14 -0
  43. package/dist/host/socket.d.ts.map +1 -0
  44. package/dist/host/socket.js +459 -0
  45. package/dist/host/socket.js.map +1 -0
  46. package/dist/host-internal.d.ts +27 -0
  47. package/dist/host-internal.d.ts.map +1 -0
  48. package/dist/host-internal.js +3 -0
  49. package/dist/host.d.ts +44 -0
  50. package/dist/host.d.ts.map +1 -0
  51. package/dist/host.js +4 -0
  52. package/dist/index.d.ts +5 -0
  53. package/dist/index.js +5 -0
  54. package/dist/statewire.d.ts +104 -0
  55. package/dist/statewire.d.ts.map +1 -0
  56. package/dist/statewire.js +53 -0
  57. package/dist/statewire.js.map +1 -0
  58. package/dist/transports/fake-sse.d.ts +12 -0
  59. package/dist/transports/fake-sse.d.ts.map +1 -0
  60. package/dist/transports/fake-sse.js +39 -0
  61. package/dist/transports/fake-sse.js.map +1 -0
  62. package/dist/transports/fixtures.d.ts +33 -0
  63. package/dist/transports/fixtures.d.ts.map +1 -0
  64. package/dist/transports/fixtures.js +50 -0
  65. package/dist/transports/fixtures.js.map +1 -0
  66. package/dist/transports/remote.d.ts +12 -0
  67. package/dist/transports/remote.d.ts.map +1 -0
  68. package/dist/transports/remote.js +21 -0
  69. package/dist/transports/remote.js.map +1 -0
  70. package/dist/transports/sse-parser.d.ts +5 -0
  71. package/dist/transports/sse-parser.d.ts.map +1 -0
  72. package/dist/transports/sse-parser.js +26 -0
  73. package/dist/transports/sse-parser.js.map +1 -0
  74. package/dist/transports/sse.d.ts +64 -0
  75. package/dist/transports/sse.d.ts.map +1 -0
  76. package/dist/transports/sse.js +137 -0
  77. package/dist/transports/sse.js.map +1 -0
  78. package/dist/transports/testing.d.ts +32 -0
  79. package/dist/transports/testing.d.ts.map +1 -0
  80. package/dist/transports/testing.js +94 -0
  81. package/dist/transports/testing.js.map +1 -0
  82. package/dist/transports/ws.d.ts +9 -0
  83. package/dist/transports/ws.d.ts.map +1 -0
  84. package/dist/transports/ws.js +169 -0
  85. package/dist/transports/ws.js.map +1 -0
  86. package/dist/types.d.ts +60 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +24 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/util/draftTraps.d.ts +6 -0
  91. package/dist/util/draftTraps.d.ts.map +1 -0
  92. package/dist/util/draftTraps.js +25 -0
  93. package/dist/util/draftTraps.js.map +1 -0
  94. package/dist/util/macrotask.d.ts +5 -0
  95. package/dist/util/macrotask.d.ts.map +1 -0
  96. package/dist/util/macrotask.js +25 -0
  97. package/dist/util/macrotask.js.map +1 -0
  98. package/dist/util/methodProxy.d.ts +5 -0
  99. package/dist/util/methodProxy.d.ts.map +1 -0
  100. package/dist/util/methodProxy.js +17 -0
  101. package/dist/util/methodProxy.js.map +1 -0
  102. package/dist/util/randomId.d.ts +5 -0
  103. package/dist/util/randomId.d.ts.map +1 -0
  104. package/dist/util/randomId.js +6 -0
  105. package/dist/util/randomId.js.map +1 -0
  106. package/dist/util/recordMutations.d.ts +10 -0
  107. package/dist/util/recordMutations.d.ts.map +1 -0
  108. package/dist/util/recordMutations.js +109 -0
  109. package/dist/util/recordMutations.js.map +1 -0
  110. package/dist/util/statePath.d.ts +14 -0
  111. package/dist/util/statePath.d.ts.map +1 -0
  112. package/dist/util/statePath.js +31 -0
  113. package/dist/util/statePath.js.map +1 -0
  114. package/dist/wire/meta.d.ts +27 -0
  115. package/dist/wire/meta.d.ts.map +1 -0
  116. package/dist/wire/meta.js +75 -0
  117. package/dist/wire/meta.js.map +1 -0
  118. package/dist/wire/ops.d.ts +19 -0
  119. package/dist/wire/ops.d.ts.map +1 -0
  120. package/dist/wire/ops.js +112 -0
  121. package/dist/wire/ops.js.map +1 -0
  122. package/package.json +52 -5
  123. package/src/client/changes.ts +38 -0
  124. package/src/client/connection.ts +301 -0
  125. package/src/client/core.ts +312 -0
  126. package/src/client/session.ts +696 -0
  127. package/src/client/sessionStore.ts +23 -0
  128. package/src/client/timing.ts +14 -0
  129. package/src/host/adapter.ts +33 -0
  130. package/src/host/draft.ts +323 -0
  131. package/src/host/engine.ts +13 -0
  132. package/src/host/host.ts +482 -0
  133. package/src/host/socket.ts +542 -0
  134. package/src/host-internal.ts +28 -0
  135. package/src/host.ts +61 -0
  136. package/src/index.ts +5 -0
  137. package/src/statewire.ts +200 -0
  138. package/src/transports/fake-sse.ts +51 -0
  139. package/src/transports/fixtures.ts +60 -0
  140. package/src/transports/remote.ts +30 -0
  141. package/src/transports/sse-parser.ts +24 -0
  142. package/src/transports/sse.ts +267 -0
  143. package/src/transports/testing.ts +123 -0
  144. package/src/transports/ws.ts +256 -0
  145. package/src/types.ts +88 -0
  146. package/src/util/draftTraps.ts +32 -0
  147. package/src/util/macrotask.ts +25 -0
  148. package/src/util/methodProxy.ts +16 -0
  149. package/src/util/randomId.ts +4 -0
  150. package/src/util/recordMutations.ts +0 -0
  151. package/src/util/statePath.ts +61 -0
  152. package/src/wire/meta.ts +101 -0
  153. package/src/wire/ops.ts +202 -0
  154. package/index.js +0 -1
@@ -0,0 +1,696 @@
1
+ import { useEffect, useState } from "react";
2
+ import { applyOps } from "../wire/ops";
3
+ import type { StatewirePath } from "../wire/ops";
4
+ import type { StatewireResponse } from "./connection";
5
+ import { bindCommandsHost } from "../host/host";
6
+ import type { CommandsInvocation, CommandsListener } from "../host/host";
7
+ import type { Statewire } from "../types";
8
+ import type { StatewireClient } from "../statewire";
9
+ import { createMemorySessionStore } from "./sessionStore";
10
+ import type {
11
+ StatewireSessionRecord,
12
+ StatewireSessionStore,
13
+ StatewireSessionStoreOptions,
14
+ } from "./sessionStore";
15
+ import { retryDelay } from "./timing";
16
+ import { createMethodProxy } from "../util/methodProxy";
17
+ import { randomId } from "../util/randomId";
18
+ import { StatewireSendError } from "../types";
19
+ import type {
20
+ RemoteLink,
21
+ StatewireRemoteProps,
22
+ StatewireSubmitResult,
23
+ StatewireWireCommand,
24
+ } from "../transports/sse";
25
+
26
+ /**
27
+ * queued/trying/posted: overlay live, result pending. awaiting-result: acked
28
+ * (overlay gone) but the result rides a late res; `posted: false` re-sends.
29
+ * settled-live: result settled but the overlay stays until the covering ack.
30
+ * done: removed from the queue.
31
+ */
32
+ type PendingState =
33
+ | { at: "queued"; pendingMarked: boolean }
34
+ | { at: "trying"; pendingMarked: boolean; failedAttempts: number }
35
+ | { at: "posted"; pendingMarked: boolean; failedAttempts: number }
36
+ | { at: "awaiting-result"; posted: boolean; failedAttempts: number }
37
+ | { at: "settled-live"; posted: boolean; failedAttempts: number }
38
+ | { at: "done" };
39
+
40
+ type Pending = {
41
+ readonly seq: number;
42
+ readonly command: StatewireWireCommand;
43
+ readonly inv: CommandsInvocation;
44
+ state: PendingState;
45
+ resolve: (payload: unknown) => void;
46
+ reject: (error: StatewireSendError) => void;
47
+ };
48
+
49
+ type Outcome =
50
+ | { ok: true; payload: unknown }
51
+ | { ok: false; failure: StatewireClient.SendFailure };
52
+
53
+ const isResolved = (state: PendingState) =>
54
+ state.at === "settled-live" || state.at === "done";
55
+
56
+ const isStateSettled = (state: PendingState) =>
57
+ state.at === "awaiting-result" || state.at === "done";
58
+
59
+ // The pump admits an entry as trying before sending; only the pump itself
60
+ // requeues, and a done entry is never still pending.
61
+ const inFlight = (state: PendingState) => {
62
+ if (state.at === "queued" || state.at === "done") {
63
+ throw new Error(`statewire: ${state.at} entry observed after a send`);
64
+ }
65
+ return state;
66
+ };
67
+
68
+ const legalTransitions: Record<PendingState["at"], PendingState["at"][]> = {
69
+ queued: ["queued", "trying", "awaiting-result", "settled-live", "done"],
70
+ // trying -> queued: an answered unknown-client proves the seq was never admitted.
71
+ trying: [
72
+ "queued",
73
+ "trying",
74
+ "posted",
75
+ "awaiting-result",
76
+ "settled-live",
77
+ "done",
78
+ ],
79
+ posted: ["posted", "trying", "awaiting-result", "settled-live", "done"],
80
+ "awaiting-result": ["awaiting-result", "done"],
81
+ "settled-live": ["settled-live", "done"],
82
+ done: [],
83
+ };
84
+
85
+ const transition = (
86
+ s: Session,
87
+ entry: Pending,
88
+ next: PendingState,
89
+ outcome?: Outcome,
90
+ ) => {
91
+ const prev = entry.state;
92
+ if (!legalTransitions[prev.at].includes(next.at)) {
93
+ throw new Error(
94
+ `statewire: illegal pending transition ${prev.at} -> ${next.at}`,
95
+ );
96
+ }
97
+ const settles = !isResolved(prev) && isResolved(next);
98
+ if (settles && outcome === undefined) {
99
+ throw new Error(
100
+ `statewire: settling transition ${prev.at} -> ${next.at} without an outcome`,
101
+ );
102
+ }
103
+ const covers = !isStateSettled(prev) && isStateSettled(next);
104
+ entry.state = next;
105
+ if (covers) s.lifecycle?.onCovered?.(entry.inv);
106
+ if (settles && outcome !== undefined) {
107
+ if (outcome.ok) {
108
+ entry.resolve(outcome.payload);
109
+ s.lifecycle?.onSettle(entry.inv, {
110
+ type: "accepted",
111
+ payload: outcome.payload,
112
+ });
113
+ } else {
114
+ entry.reject(new StatewireSendError(outcome.failure));
115
+ s.lifecycle?.onSettle(entry.inv, {
116
+ type: "rejected",
117
+ error: new StatewireSendError(outcome.failure),
118
+ });
119
+ }
120
+ }
121
+ if (next.at === "done") {
122
+ s.pending = s.pending.filter((p) => p !== entry);
123
+ }
124
+ };
125
+
126
+ const carried = (
127
+ state: Exclude<PendingState, { at: "done" }>,
128
+ ): { posted: boolean; failedAttempts: number } =>
129
+ state.at === "queued"
130
+ ? { posted: false, failedAttempts: 0 }
131
+ : state.at === "trying" || state.at === "posted"
132
+ ? { posted: state.at === "posted", failedAttempts: state.failedAttempts }
133
+ : { posted: state.posted, failedAttempts: state.failedAttempts };
134
+
135
+ const newClientId = () => `c-${randomId()}`;
136
+
137
+ const rejectedSend = (
138
+ failure: StatewireClient.SendFailure,
139
+ ): Promise<unknown> => {
140
+ const result = Promise.reject<unknown>(new StatewireSendError(failure));
141
+ result.catch(() => {});
142
+ return result;
143
+ };
144
+
145
+ type AttachState = "detached" | "attached" | "finished";
146
+
147
+ type Session = {
148
+ record: StatewireSessionRecord;
149
+ ctx: StatewireRemoteProps;
150
+ recordPersisted: boolean;
151
+ store: StatewireSessionStore;
152
+ lastAck: number;
153
+ pending: Pending[];
154
+ canonical: unknown;
155
+ lastPublished: unknown;
156
+ changed: StatewirePath[];
157
+ source: Statewire.StateSource;
158
+ attach: AttachState;
159
+ attachEpoch: number;
160
+ running: boolean;
161
+ pumping: boolean;
162
+ transport: RemoteLink | null;
163
+ waiters: (() => void)[];
164
+ lifecycle: CommandsListener | null;
165
+ notify: () => void;
166
+ emitEvent: (event: unknown) => void;
167
+ send: (command: StatewireWireCommand) => Promise<unknown>;
168
+ commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
169
+ };
170
+
171
+ const createSession = (options: StatewireSessionStoreOptions): Session => {
172
+ const store = options.sessionStore ?? createMemorySessionStore();
173
+ const stored = store.load();
174
+ const record = stored ?? { clientId: newClientId(), nextSeq: 1 };
175
+ const s: Session = {
176
+ record,
177
+ ctx: {
178
+ get clientId() {
179
+ return s.record.clientId;
180
+ },
181
+ onSnapshot: (args) => handleSnapshot(s, args),
182
+ onOps: (args) => handleOps(s, args),
183
+ onFinish: (args) => handleFinish(s, args),
184
+ },
185
+ recordPersisted: stored !== null,
186
+ store,
187
+ lastAck: -1,
188
+ pending: [],
189
+ canonical: undefined,
190
+ lastPublished: undefined,
191
+ changed: [],
192
+ source: {
193
+ snapshot: () => s.canonical,
194
+ takeChanged: () => {
195
+ const changed = s.changed;
196
+ s.changed = [];
197
+ return changed;
198
+ },
199
+ setOnChange: (listener) => {
200
+ s.notify = listener ?? (() => {});
201
+ },
202
+ },
203
+ attach: "detached",
204
+ attachEpoch: 0,
205
+ running: false,
206
+ pumping: false,
207
+ transport: null,
208
+ waiters: [],
209
+ lifecycle: null,
210
+ notify: () => {},
211
+ emitEvent: () => {},
212
+ send: (command) => sendCommand(s, command),
213
+ commands: createMethodProxy((method, params) => s.send({ method, params })),
214
+ };
215
+ bindCommandsHost(s.commands, {
216
+ setListener: (listener) => {
217
+ s.lifecycle = listener;
218
+ },
219
+ });
220
+ return s;
221
+ };
222
+
223
+ const publish = (s: Session) => {
224
+ if (s.canonical === s.lastPublished) return;
225
+ s.lastPublished = s.canonical;
226
+ s.notify();
227
+ };
228
+
229
+ const wake = (s: Session) => {
230
+ const waiters = s.waiters;
231
+ s.waiters = [];
232
+ for (const waiter of waiters) waiter();
233
+ };
234
+
235
+ const waitForWake = (s: Session) => new Promise<void>((r) => s.waiters.push(r));
236
+
237
+ const sleepUntilWake = (s: Session, delayMs: number) =>
238
+ new Promise<void>((resolve) => {
239
+ const waiter = () => {
240
+ clearTimeout(timer);
241
+ resolve();
242
+ };
243
+ const timer = setTimeout(() => {
244
+ s.waiters = s.waiters.filter((w) => w !== waiter);
245
+ resolve();
246
+ }, delayMs);
247
+ s.waiters.push(waiter);
248
+ });
249
+
250
+ const settleAll = (
251
+ s: Session,
252
+ detach: "evicted" | "unmount" | "gone" | "superseded",
253
+ verdict?: {
254
+ entry: Pending;
255
+ failure: StatewireClient.SendFailure;
256
+ },
257
+ ) => {
258
+ const cause = (
259
+ {
260
+ evicted: "session-lost",
261
+ unmount: "unmount",
262
+ gone: "stream-gone",
263
+ superseded: "superseded",
264
+ } as const
265
+ )[detach];
266
+ const batch = s.pending;
267
+ const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
268
+ const state = entry.state;
269
+ if (state.at === "awaiting-result")
270
+ return { fate: "result-unavailable", cause };
271
+ if (state.at === "queued") {
272
+ const cascades = batch.some(
273
+ (p) => p.seq < entry.seq && p.state.at !== "queued",
274
+ );
275
+ return { fate: "aborted", cause: cascades ? "cascade" : cause };
276
+ }
277
+ return { fate: "lost", cause };
278
+ };
279
+ for (const entry of [...batch]) {
280
+ const failure =
281
+ entry === verdict?.entry ? verdict.failure : detachFailure(entry);
282
+ transition(s, entry, { at: "done" }, { ok: false, failure });
283
+ }
284
+ };
285
+
286
+ const evict = (
287
+ s: Session,
288
+ verdict?: {
289
+ entry: Pending;
290
+ failure: StatewireClient.SendFailure;
291
+ },
292
+ ) => {
293
+ settleAll(s, "evicted", verdict);
294
+ s.record = { clientId: newClientId(), nextSeq: 1 };
295
+ s.store.save(s.record);
296
+ s.attach = "detached";
297
+ publish(s);
298
+ wake(s);
299
+ };
300
+
301
+ const noteAcked = (s: Session, seq: number) => {
302
+ for (const entry of s.pending) {
303
+ if (entry.seq > seq || isStateSettled(entry.state)) continue;
304
+ s.lifecycle?.onAck(entry.inv);
305
+ }
306
+ };
307
+
308
+ const spliceAcked = (s: Session, seq: number, settleVoid: boolean) => {
309
+ for (const entry of [...s.pending]) {
310
+ const state = entry.state;
311
+ if (entry.seq > seq || isStateSettled(state)) continue;
312
+ if (state.at === "settled-live") {
313
+ transition(s, entry, { at: "done" });
314
+ } else if (settleVoid && !state.pendingMarked) {
315
+ transition(s, entry, { at: "done" }, { ok: true, payload: undefined });
316
+ } else {
317
+ transition(s, entry, { at: "awaiting-result", ...carried(state) });
318
+ }
319
+ }
320
+ };
321
+
322
+ const protocolError = (message: string): never => {
323
+ throw new Error(`statewire: protocol error: ${message}`);
324
+ };
325
+
326
+ const applyResponse = (s: Session, response: StatewireResponse) => {
327
+ const entry = s.pending.find((p) => p.seq === response.seq);
328
+ if (!entry) return;
329
+ const state = entry.state;
330
+ if (state.at === "done") return;
331
+ switch (response.type) {
332
+ case "pending":
333
+ if (
334
+ state.at === "queued" ||
335
+ state.at === "trying" ||
336
+ state.at === "posted"
337
+ ) {
338
+ transition(s, entry, { ...state, pendingMarked: true });
339
+ }
340
+ break;
341
+ case "accepted":
342
+ if (state.at === "settled-live") break;
343
+ transition(
344
+ s,
345
+ entry,
346
+ state.at === "awaiting-result"
347
+ ? { at: "done" }
348
+ : { at: "settled-live", ...carried(state) },
349
+ { ok: true, payload: response.payload },
350
+ );
351
+ break;
352
+ case "rejected":
353
+ transition(
354
+ s,
355
+ entry,
356
+ { at: "done" },
357
+ {
358
+ ok: false,
359
+ failure:
360
+ response.code === "unknown-command"
361
+ ? { fate: "unknown-command", cause: "server" }
362
+ : response.code === "invalid-params"
363
+ ? {
364
+ fate: "invalid-params",
365
+ cause: "server",
366
+ message:
367
+ response.message ??
368
+ protocolError("rejected response missing message"),
369
+ }
370
+ : {
371
+ fate: "rejected",
372
+ cause: "server",
373
+ message:
374
+ response.message ??
375
+ protocolError("rejected response missing message"),
376
+ ...(response.payload !== undefined && {
377
+ payload: response.payload,
378
+ }),
379
+ },
380
+ },
381
+ );
382
+ break;
383
+ case "crashed":
384
+ case "result-unavailable":
385
+ if (state.at === "settled-live") break;
386
+ transition(
387
+ s,
388
+ entry,
389
+ state.at === "awaiting-result"
390
+ ? { at: "done" }
391
+ : { at: "settled-live", ...carried(state) },
392
+ { ok: false, failure: { fate: response.type, cause: "server" } },
393
+ );
394
+ break;
395
+ }
396
+ };
397
+
398
+ const ingestOps = (s: Session, ops: unknown[]) => {
399
+ const applied = applyOps(s.canonical, ops);
400
+ s.canonical = applied.state;
401
+ s.changed.push(...applied.changedPaths);
402
+ return applied.events;
403
+ };
404
+
405
+ const publishIngested = (s: Session, events: unknown[]) => {
406
+ publish(s);
407
+ for (const event of events) s.emitEvent(event);
408
+ wake(s);
409
+ };
410
+
411
+ const handleSnapshot = (
412
+ s: Session,
413
+ { ops, lastSeq }: { ops: unknown[]; lastSeq: number },
414
+ ) => {
415
+ if (lastSeq === -1 && s.attach !== "detached") {
416
+ evict(s);
417
+ s.transport?.connection.reconnect();
418
+ publishIngested(s, ingestOps(s, ops));
419
+ return;
420
+ }
421
+ const events = ingestOps(s, ops);
422
+ s.attach = "attached";
423
+ s.attachEpoch += 1;
424
+ // The attach snapshot is not the true covering envelope: acked entries
425
+ // stay awaiting their result and re-send under seq dedup.
426
+ noteAcked(s, lastSeq);
427
+ spliceAcked(s, lastSeq, false);
428
+ s.lastAck = lastSeq;
429
+ for (const entry of s.pending) {
430
+ const state = entry.state;
431
+ if (entry.seq <= lastSeq) {
432
+ if (state.at === "awaiting-result" && state.posted) {
433
+ transition(s, entry, { ...state, posted: false });
434
+ }
435
+ if (state.at === "awaiting-result") void pump(s);
436
+ } else if (state.at === "posted") {
437
+ // Above the ack watermark a prior receipt no longer counts.
438
+ transition(s, entry, {
439
+ at: "trying",
440
+ pendingMarked: state.pendingMarked,
441
+ failedAttempts: state.failedAttempts,
442
+ });
443
+ void pump(s);
444
+ } else if (state.at === "settled-live" && state.posted) {
445
+ transition(s, entry, { ...state, posted: false });
446
+ void pump(s);
447
+ }
448
+ }
449
+ publishIngested(s, events);
450
+ };
451
+
452
+ const handleOps = (
453
+ s: Session,
454
+ {
455
+ ops,
456
+ res,
457
+ ack,
458
+ }: { ops: unknown[]; res?: StatewireResponse[]; ack?: number },
459
+ ) => {
460
+ const horizon = ack === undefined ? s.lastAck : Math.max(s.lastAck, ack);
461
+ if (res !== undefined) {
462
+ for (const response of res) {
463
+ if (response.seq > horizon) {
464
+ protocolError("response seq beyond the ack");
465
+ }
466
+ }
467
+ }
468
+ const events = ingestOps(s, ops);
469
+ if (ack !== undefined) noteAcked(s, ack);
470
+ if (res !== undefined) for (const response of res) applyResponse(s, response);
471
+ if (ack !== undefined) {
472
+ spliceAcked(s, ack, true);
473
+ s.lastAck = Math.max(s.lastAck, ack);
474
+ }
475
+ publishIngested(s, events);
476
+ };
477
+
478
+ const handleFinish = (s: Session, { reason }: { reason: string }) => {
479
+ if (reason === "evicted" || reason === "error") return;
480
+ s.attach = "finished";
481
+ settleAll(s, reason === "superseded" ? "superseded" : "gone");
482
+ publish(s);
483
+ wake(s);
484
+ };
485
+
486
+ const isFinished = (s: Session) => s.attach === "finished";
487
+
488
+ const sendable = (p: Pending) =>
489
+ p.state.at === "queued" ||
490
+ p.state.at === "trying" ||
491
+ ((p.state.at === "awaiting-result" || p.state.at === "settled-live") &&
492
+ !p.state.posted);
493
+
494
+ const pump = async (s: Session) => {
495
+ if (s.pumping) return;
496
+ s.pumping = true;
497
+ try {
498
+ while (s.running && !isFinished(s)) {
499
+ const entry = s.pending.find(sendable);
500
+ if (!entry) return;
501
+ const transport = s.transport;
502
+ if (s.attach !== "attached" || !transport) {
503
+ await waitForWake(s);
504
+ continue;
505
+ }
506
+ const clientId = s.record.clientId;
507
+ const epoch = s.attachEpoch;
508
+ if (entry.state.at === "queued") {
509
+ transition(s, entry, {
510
+ at: "trying",
511
+ pendingMarked: entry.state.pendingMarked,
512
+ failedAttempts: 0,
513
+ });
514
+ }
515
+ let result: StatewireSubmitResult;
516
+ try {
517
+ result = await transport.send({
518
+ seq: entry.seq,
519
+ command: entry.command,
520
+ });
521
+ } catch (error) {
522
+ if (!s.running || isFinished(s)) return;
523
+ if (!s.pending.includes(entry)) continue;
524
+ const state = inFlight(entry.state);
525
+ transition(s, entry, {
526
+ ...state,
527
+ failedAttempts: state.failedAttempts + 1,
528
+ });
529
+ await sleepUntilWake(s, retryDelay(error, state.failedAttempts + 1));
530
+ continue;
531
+ }
532
+ if (!s.running) return;
533
+ if (!s.pending.includes(entry)) continue;
534
+ if (result.type === "receipt") {
535
+ const response = result.response;
536
+ const state = entry.state;
537
+ if (state.at === "trying" || state.at === "posted") {
538
+ transition(s, entry, {
539
+ at: "posted",
540
+ pendingMarked: state.pendingMarked,
541
+ failedAttempts: state.failedAttempts,
542
+ });
543
+ } else if (
544
+ state.at === "awaiting-result" ||
545
+ state.at === "settled-live"
546
+ ) {
547
+ transition(s, entry, { ...state, posted: true });
548
+ }
549
+ s.lifecycle?.onPosted?.(entry.inv);
550
+ if (response !== undefined) {
551
+ applyResponse(s, response);
552
+ publish(s);
553
+ }
554
+ } else if (result.type === "rejected") {
555
+ transition(
556
+ s,
557
+ entry,
558
+ { at: "done" },
559
+ {
560
+ ok: false,
561
+ failure: {
562
+ fate: "rejected",
563
+ cause: "server",
564
+ message: result.message,
565
+ },
566
+ },
567
+ );
568
+ publish(s);
569
+ } else if (result.type === "stale-lease") {
570
+ if (epoch === s.attachEpoch) await waitForWake(s);
571
+ } else if (result.type === "unknown-client") {
572
+ // Eviction is never decided on the POST leg: re-attach first and let
573
+ // the snapshot's lastSeq deliver the verdict. An answered attempt
574
+ // proves the server never admitted this seq, so a clean entry requeues.
575
+ if (epoch === s.attachEpoch) {
576
+ const state = entry.state;
577
+ if (state.at === "trying" && state.failedAttempts === 0) {
578
+ transition(s, entry, {
579
+ at: "queued",
580
+ pendingMarked: state.pendingMarked,
581
+ });
582
+ }
583
+ s.transport?.connection.reconnect();
584
+ while (
585
+ s.running &&
586
+ !isFinished(s) &&
587
+ epoch === s.attachEpoch &&
588
+ clientId === s.record.clientId
589
+ ) {
590
+ await waitForWake(s);
591
+ }
592
+ }
593
+ } else if (result.type === "seq-gap") {
594
+ if (clientId === s.record.clientId) {
595
+ console.error(
596
+ `statewire: server reports a seq gap for seq ${entry.seq}; ` +
597
+ "treating the session as corrupt and rotating the client id",
598
+ );
599
+ // Every attempt answered means the server never saw it; a thrown
600
+ // attempt may have left the machine unanswered, so the verdict is "lost".
601
+ const state = inFlight(entry.state);
602
+ const failure: StatewireClient.SendFailure =
603
+ state.at === "awaiting-result"
604
+ ? { fate: "result-unavailable", cause: "session-lost" }
605
+ : {
606
+ fate: state.failedAttempts > 0 ? "lost" : "aborted",
607
+ cause: "session-lost",
608
+ };
609
+ evict(s, { entry, failure });
610
+ s.transport?.connection.reconnect();
611
+ }
612
+ }
613
+ }
614
+ } finally {
615
+ s.pumping = false;
616
+ }
617
+ };
618
+
619
+ const start = (s: Session) => {
620
+ if (!s.recordPersisted) {
621
+ s.recordPersisted = true;
622
+ s.store.save(s.record);
623
+ }
624
+ s.running = true;
625
+ void pump(s);
626
+ };
627
+
628
+ const stop = (s: Session) => {
629
+ s.running = false;
630
+ wake(s);
631
+ settleAll(s, "unmount");
632
+ };
633
+
634
+ const setTransport = (s: Session, transport: RemoteLink) => {
635
+ if (s.transport === transport) return;
636
+ s.transport = transport;
637
+ wake(s);
638
+ };
639
+
640
+ const sendCommand = (
641
+ s: Session,
642
+ command: StatewireWireCommand,
643
+ ): Promise<unknown> => {
644
+ if (!s.running) {
645
+ return rejectedSend({ fate: "aborted", cause: "unmount" });
646
+ }
647
+ if (isFinished(s)) {
648
+ return rejectedSend({ fate: "aborted", cause: "stream-gone" });
649
+ }
650
+ const seq = s.record.nextSeq;
651
+ s.record = { ...s.record, nextSeq: seq + 1 };
652
+ s.store.save(s.record);
653
+ const entry: Pending = {
654
+ seq,
655
+ command,
656
+ inv: {
657
+ method: command.method,
658
+ params: command.params,
659
+ ts: Date.now(),
660
+ seq,
661
+ clientId: s.record.clientId,
662
+ },
663
+ state: { at: "queued", pendingMarked: false },
664
+ resolve: () => {},
665
+ reject: () => {},
666
+ };
667
+ const result = new Promise<unknown>((resolve, reject) => {
668
+ entry.resolve = resolve;
669
+ entry.reject = reject;
670
+ });
671
+ result.catch(() => {});
672
+ s.pending.push(entry);
673
+ s.lifecycle?.onStart(entry.inv);
674
+ void pump(s);
675
+ return result;
676
+ };
677
+
678
+ export const useStatewireSession = (
679
+ options: StatewireSessionStoreOptions,
680
+ emit: (event: unknown) => void,
681
+ ): Session => {
682
+ const [s] = useState(() => createSession(options));
683
+ useEffect(() => {
684
+ s.emitEvent = emit;
685
+ return () => {
686
+ s.emitEvent = () => {};
687
+ };
688
+ }, [s, emit]);
689
+ useEffect(() => {
690
+ start(s);
691
+ return () => stop(s);
692
+ }, [s]);
693
+ return s;
694
+ };
695
+
696
+ export { setTransport };
@@ -0,0 +1,23 @@
1
+ export type StatewireSessionRecord = {
2
+ clientId: string;
3
+ nextSeq: number;
4
+ };
5
+
6
+ export type StatewireSessionStore = {
7
+ load: () => StatewireSessionRecord | null;
8
+ save: (record: StatewireSessionRecord) => void;
9
+ };
10
+
11
+ export type StatewireSessionStoreOptions = {
12
+ sessionStore?: StatewireSessionStore | undefined;
13
+ };
14
+
15
+ export const createMemorySessionStore = (): StatewireSessionStore => {
16
+ let record: StatewireSessionRecord | null = null;
17
+ return {
18
+ load: () => record,
19
+ save: (next) => {
20
+ record = next;
21
+ },
22
+ };
23
+ };