statewire 0.8.1 → 0.9.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.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +23 -19
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +143 -103
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +30 -3
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +222 -260
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +34 -39
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +380 -373
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/client/sessionStore.d.ts +1 -0
  15. package/dist/client/sessionStore.d.ts.map +1 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/client/timing.d.ts +1 -2
  18. package/dist/client/timing.d.ts.map +1 -1
  19. package/dist/client/timing.js +1 -5
  20. package/dist/client/timing.js.map +1 -1
  21. package/dist/host/draft.d.ts +2 -5
  22. package/dist/host/draft.d.ts.map +1 -1
  23. package/dist/host/draft.js.map +1 -1
  24. package/dist/host/host.d.ts +5 -8
  25. package/dist/host/host.d.ts.map +1 -1
  26. package/dist/host/host.js +23 -32
  27. package/dist/host/host.js.map +1 -1
  28. package/dist/host/socket.d.ts +3 -2
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +484 -424
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +4 -0
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +10 -10
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -2
  37. package/dist/index.d.ts +1 -2
  38. package/dist/index.js +1 -2
  39. package/dist/statewire.d.ts +29 -24
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +8 -2
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/fake-sse.d.ts +1 -1
  44. package/dist/transports/fake-sse.d.ts.map +1 -1
  45. package/dist/transports/fake-sse.js +5 -2
  46. package/dist/transports/fake-sse.js.map +1 -1
  47. package/dist/transports/fixtures.d.ts +1 -2
  48. package/dist/transports/fixtures.d.ts.map +1 -1
  49. package/dist/transports/fixtures.js +5 -7
  50. package/dist/transports/fixtures.js.map +1 -1
  51. package/dist/transports/remote.d.ts +2 -1
  52. package/dist/transports/remote.d.ts.map +1 -1
  53. package/dist/transports/remote.js +10 -4
  54. package/dist/transports/remote.js.map +1 -1
  55. package/dist/transports/sse.d.ts +32 -46
  56. package/dist/transports/sse.d.ts.map +1 -1
  57. package/dist/transports/sse.js +56 -59
  58. package/dist/transports/sse.js.map +1 -1
  59. package/dist/transports/testing.d.ts +7 -5
  60. package/dist/transports/testing.d.ts.map +1 -1
  61. package/dist/transports/testing.js +41 -14
  62. package/dist/transports/testing.js.map +1 -1
  63. package/dist/transports/ws.d.ts +2 -3
  64. package/dist/transports/ws.d.ts.map +1 -1
  65. package/dist/transports/ws.js +25 -80
  66. package/dist/transports/ws.js.map +1 -1
  67. package/dist/types.d.ts +8 -8
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +3 -4
  70. package/dist/types.js.map +1 -1
  71. package/dist/wire/ops.d.ts +1 -5
  72. package/dist/wire/ops.d.ts.map +1 -1
  73. package/dist/wire/ops.js +1 -4
  74. package/dist/wire/ops.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +175 -128
  77. package/src/client/core.ts +287 -278
  78. package/src/client/session.ts +474 -480
  79. package/src/client/sessionStore.ts +1 -0
  80. package/src/client/timing.ts +0 -6
  81. package/src/host/draft.ts +1 -3
  82. package/src/host/host.ts +41 -58
  83. package/src/host/socket.ts +653 -448
  84. package/src/host-internal.ts +4 -0
  85. package/src/host.ts +13 -10
  86. package/src/index.ts +0 -1
  87. package/src/statewire.ts +38 -32
  88. package/src/transports/fake-sse.ts +2 -1
  89. package/src/transports/fixtures.ts +4 -9
  90. package/src/transports/remote.ts +12 -5
  91. package/src/transports/sse.ts +99 -115
  92. package/src/transports/testing.ts +47 -19
  93. package/src/transports/ws.ts +44 -115
  94. package/src/types.ts +15 -14
  95. package/src/wire/ops.ts +9 -16
@@ -2,78 +2,14 @@ import { applyOps } from "../wire/ops.js";
2
2
  import { createMethodProxy } from "../util/methodProxy.js";
3
3
  import { StatewireSendError } from "../types.js";
4
4
  import { bindCommandsHost } from "../host/host.js";
5
- import { randomId } from "../util/randomId.js";
6
- import { retryDelay } from "./timing.js";
5
+ import { encodeParams, encodeValue, reviveResponse } from "../wire/meta.js";
6
+ import { backoff } from "./timing.js";
7
7
  import { createMemorySessionStore } from "./sessionStore.js";
8
+ import { randomId } from "../util/randomId.js";
8
9
  import { useEffect, useState } from "@assistant-ui/tap/react-shim";
9
10
  //#region src/client/session.ts
10
- const isResolved = (state) => state.at === "settled-live" || state.at === "done";
11
- const isStateSettled = (state) => state.at === "awaiting-result" || state.at === "done";
12
- const inFlight = (state) => {
13
- if (state.at === "queued" || state.at === "done") throw new Error(`statewire: ${state.at} entry observed after a send`);
14
- return state;
15
- };
16
- const legalTransitions = {
17
- queued: [
18
- "queued",
19
- "trying",
20
- "awaiting-result",
21
- "settled-live",
22
- "done"
23
- ],
24
- trying: [
25
- "queued",
26
- "trying",
27
- "posted",
28
- "awaiting-result",
29
- "settled-live",
30
- "done"
31
- ],
32
- posted: [
33
- "posted",
34
- "trying",
35
- "awaiting-result",
36
- "settled-live",
37
- "done"
38
- ],
39
- "awaiting-result": ["awaiting-result", "done"],
40
- "settled-live": ["settled-live", "done"],
41
- done: []
42
- };
43
- const transition = (s, entry, next, outcome) => {
44
- const prev = entry.state;
45
- if (!legalTransitions[prev.at].includes(next.at)) throw new Error(`statewire: illegal pending transition ${prev.at} -> ${next.at}`);
46
- const settles = !isResolved(prev) && isResolved(next);
47
- if (settles && outcome === void 0) throw new Error(`statewire: settling transition ${prev.at} -> ${next.at} without an outcome`);
48
- const covers = !isStateSettled(prev) && isStateSettled(next);
49
- entry.state = next;
50
- if (covers && entry.inv) s.lifecycle?.onCovered?.(entry.inv);
51
- if (settles && outcome !== void 0) {
52
- if (outcome.ok) {
53
- entry.resolve(outcome.payload);
54
- if (entry.inv) s.lifecycle?.onSettle(entry.inv, {
55
- type: "accepted",
56
- payload: outcome.payload
57
- });
58
- } else {
59
- entry.reject(new StatewireSendError(outcome.failure));
60
- if (entry.inv) s.lifecycle?.onSettle(entry.inv, {
61
- type: "rejected",
62
- error: new StatewireSendError(outcome.failure)
63
- });
64
- }
65
- }
66
- if (next.at === "done") s.pending = s.pending.filter((p) => p !== entry);
67
- };
68
- const carried = (state) => state.at === "queued" ? {
69
- posted: false,
70
- failedAttempts: 0
71
- } : state.at === "trying" || state.at === "posted" ? {
72
- posted: state.at === "posted",
73
- failedAttempts: state.failedAttempts
74
- } : {
75
- posted: state.posted,
76
- failedAttempts: state.failedAttempts
11
+ const protocolError = (message) => {
12
+ throw new Error(`statewire: protocol error: ${message}`);
77
13
  };
78
14
  const newClientId = () => `c-${randomId()}`;
79
15
  const rejectedSend = (failure) => {
@@ -81,61 +17,6 @@ const rejectedSend = (failure) => {
81
17
  result.catch(() => {});
82
18
  return result;
83
19
  };
84
- const createSession = (options) => {
85
- const store = options.sessionStore ?? createMemorySessionStore();
86
- const stored = store.load();
87
- const s = {
88
- record: stored ?? {
89
- clientId: newClientId(),
90
- nextSeq: 1
91
- },
92
- ctx: {
93
- get clientId() {
94
- return s.record.clientId;
95
- },
96
- onSnapshot: (args) => handleSnapshot(s, args),
97
- onOps: (args) => handleOps(s, args),
98
- onFinish: (args) => handleFinish(s, args)
99
- },
100
- recordPersisted: stored !== null,
101
- store,
102
- lastAck: -1,
103
- context: void 0,
104
- pending: [],
105
- canonical: void 0,
106
- lastPublished: void 0,
107
- changed: [],
108
- source: {
109
- snapshot: () => s.canonical,
110
- takeChanged: () => {
111
- const changed = s.changed;
112
- s.changed = [];
113
- return changed;
114
- },
115
- setOnChange: (listener) => {
116
- s.notify = listener ?? (() => {});
117
- }
118
- },
119
- attach: "detached",
120
- attachEpoch: 0,
121
- running: false,
122
- pumping: false,
123
- transport: null,
124
- waiters: [],
125
- lifecycle: null,
126
- notify: () => {},
127
- emitEvent: () => {},
128
- send: (command) => sendCommand(s, command),
129
- commands: createMethodProxy((method, params) => s.send({
130
- method,
131
- params
132
- }))
133
- };
134
- bindCommandsHost(s.commands, { setListener: (listener) => {
135
- s.lifecycle = listener;
136
- } });
137
- return s;
138
- };
139
20
  const publish = (s) => {
140
21
  if (s.canonical === s.lastPublished) return;
141
22
  s.lastPublished = s.canonical;
@@ -158,22 +39,48 @@ const sleepUntilWake = (s, delayMs) => new Promise((resolve) => {
158
39
  }, delayMs);
159
40
  s.waiters.push(waiter);
160
41
  });
161
- const settleAll = (s, detach, verdict) => {
42
+ const setRecord = (s, record) => {
43
+ s.record = record;
44
+ if (!s.ephemeral) s.store.save(record);
45
+ };
46
+ const setIdle = (s, level) => {
47
+ if (level === s.idle) return;
48
+ s.idle = level;
49
+ s.notify();
50
+ if (level) s.onDone?.();
51
+ };
52
+ const cover = (s, entry) => {
53
+ if (entry.applied) return;
54
+ entry.applied = true;
55
+ s.lifecycle?.onApplied(entry.inv);
56
+ s.lifecycle?.onCovered?.(entry.inv);
57
+ };
58
+ const settleEntry = (s, entry, outcome) => {
59
+ s.pending = s.pending.filter((p) => p !== entry);
60
+ if (outcome.ok) {
61
+ entry.resolve(outcome.payload);
62
+ s.lifecycle?.onSettle(entry.inv, {
63
+ type: "accepted",
64
+ payload: outcome.payload
65
+ });
66
+ } else {
67
+ entry.reject(new StatewireSendError(outcome.failure));
68
+ s.lifecycle?.onSettle(entry.inv, {
69
+ type: "rejected",
70
+ error: new StatewireSendError(outcome.failure)
71
+ });
72
+ }
73
+ };
74
+ const settleAll = (s, detach) => {
162
75
  const cause = {
163
- evicted: "session-lost",
164
76
  unmount: "unmount",
165
77
  gone: "stream-gone",
166
78
  superseded: "superseded"
167
79
  }[detach];
168
80
  const batch = s.pending;
169
81
  const detachFailure = (entry) => {
170
- const state = entry.state;
171
- if (state.at === "awaiting-result") return {
172
- fate: "result-unavailable",
173
- cause
174
- };
175
- if (state.at === "queued") {
176
- const predecessorLost = batch.some((p) => p.seq < entry.seq && p.state.at !== "queued");
82
+ if (!entry.attempted) {
83
+ const predecessorLost = batch.some((p) => p.seq < entry.seq && p.attempted);
177
84
  return predecessorLost ? {
178
85
  fate: "aborted",
179
86
  cause,
@@ -189,177 +96,198 @@ const settleAll = (s, detach, verdict) => {
189
96
  };
190
97
  };
191
98
  for (const entry of [...batch]) {
192
- const failure = entry === verdict?.entry ? verdict.failure : detachFailure(entry);
193
- transition(s, entry, { at: "done" }, {
99
+ s.lifecycle?.onCovered?.(entry.inv);
100
+ settleEntry(s, entry, {
194
101
  ok: false,
195
- failure
102
+ failure: detachFailure(entry)
196
103
  });
197
104
  }
198
105
  };
199
- const claimReplays = (s) => {
200
- const claimed = s.pending.filter((entry) => entry.inv !== null && !isResolved(entry.state));
201
- if (claimed.length === 0) return [];
202
- s.pending = s.pending.filter((p) => !claimed.includes(p));
203
- return claimed;
204
- };
205
- const evict = (s, verdict) => {
206
- const replays = claimReplays(s);
207
- settleAll(s, "evicted", verdict);
208
- s.record = {
209
- clientId: newClientId(),
210
- nextSeq: 1
211
- };
212
- s.store.save(s.record);
213
- s.attach = "detached";
214
- if (s.context !== void 0) s.send({ context: s.context });
215
- for (const old of replays) {
216
- if (old.inv) s.lifecycle?.onCovered?.(old.inv);
217
- enqueue(s, old.command, old.resolve, old.reject);
106
+ const restateContext = (s) => {
107
+ s.ctxPending = [];
108
+ if (s.ctxOps === void 0) return;
109
+ const seq = s.record.ctxNextSeq;
110
+ setRecord(s, {
111
+ ...s.record,
112
+ ctxNextSeq: seq + 1
113
+ });
114
+ s.ctxPending.push({
115
+ seq,
116
+ ops: s.ctxOps,
117
+ posted: false
118
+ });
119
+ };
120
+ const resetRecord = (s, clientId) => {
121
+ const survivors = [];
122
+ for (const entry of [...s.pending]) if (entry.attempted || entry.applied) {
123
+ s.lifecycle?.onCovered?.(entry.inv);
124
+ settleEntry(s, entry, {
125
+ ok: false,
126
+ failure: {
127
+ fate: "lost",
128
+ cause: "session-lost"
129
+ }
130
+ });
131
+ } else survivors.push(entry);
132
+ s.pending = [];
133
+ s.durableSeq = 0;
134
+ setRecord(s, {
135
+ clientId,
136
+ nextSeq: 1,
137
+ ctxNextSeq: 1
138
+ });
139
+ for (const old of survivors) {
140
+ s.lifecycle?.onCovered?.(old.inv);
141
+ enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
218
142
  }
143
+ restateContext(s);
219
144
  publish(s);
220
145
  wake(s);
221
146
  };
222
- const noteAcked = (s, seq) => {
223
- for (const entry of s.pending) {
224
- if (entry.seq > seq || isStateSettled(entry.state)) continue;
225
- if (entry.inv) s.lifecycle?.onAck(entry.inv);
226
- }
147
+ const ephemeralDetach = (s) => {
148
+ s.ephemeral = false;
149
+ s.sessionKnown = false;
150
+ resetRecord(s, newClientId());
227
151
  };
228
- const spliceAcked = (s, seq, settleVoid) => {
229
- for (const entry of [...s.pending]) {
230
- const state = entry.state;
231
- if (entry.seq > seq || isStateSettled(state)) continue;
232
- if (state.at === "settled-live") transition(s, entry, { at: "done" });
233
- else if (settleVoid && !state.pendingMarked) transition(s, entry, { at: "done" }, {
234
- ok: true,
235
- payload: void 0
236
- });
237
- else transition(s, entry, {
238
- at: "awaiting-result",
239
- ...carried(state)
240
- });
241
- }
242
- };
243
- const protocolError = (message) => {
244
- throw new Error(`statewire: protocol error: ${message}`);
152
+ const rejectedFailure = (cmd) => {
153
+ const code = cmd.code === void 0 || cmd.code === "unknown-command" || cmd.code === "invalid-params" ? cmd.code : protocolError(`unknown rejected code: ${cmd.code}`);
154
+ return {
155
+ fate: "rejected",
156
+ cause: "server",
157
+ ...code !== void 0 && { code },
158
+ ...cmd.message !== void 0 && { message: cmd.message },
159
+ ...cmd.payload !== void 0 && { payload: cmd.payload }
160
+ };
245
161
  };
246
- const applyResponse = (s, response) => {
247
- const entry = s.pending.find((p) => p.seq === response.seq);
248
- if (!entry) return;
249
- const state = entry.state;
250
- if (state.at === "done") return;
251
- switch (response.type) {
252
- case "pending":
253
- if (state.at === "queued" || state.at === "trying" || state.at === "posted") transition(s, entry, {
254
- ...state,
255
- pendingMarked: true
256
- });
257
- break;
258
- case "accepted":
259
- if (state.at === "settled-live") break;
260
- transition(s, entry, state.at === "awaiting-result" ? { at: "done" } : {
261
- at: "settled-live",
262
- ...carried(state)
263
- }, {
264
- ok: true,
265
- payload: response.payload
266
- });
267
- break;
268
- case "rejected":
269
- transition(s, entry, { at: "done" }, {
270
- ok: false,
271
- failure: response.code === "unknown-command" ? {
272
- fate: "unknown-command",
273
- cause: "server"
274
- } : response.code === "invalid-params" ? {
275
- fate: "invalid-params",
276
- cause: "server",
277
- message: response.message ?? protocolError("rejected response missing message")
278
- } : {
279
- fate: "rejected",
280
- cause: "server",
281
- message: response.message ?? protocolError("rejected response missing message"),
282
- ...response.payload !== void 0 && { payload: response.payload }
283
- }
284
- });
285
- break;
286
- case "crashed":
287
- case "result-unavailable":
288
- if (state.at === "settled-live") break;
289
- transition(s, entry, state.at === "awaiting-result" ? { at: "done" } : {
290
- at: "settled-live",
291
- ...carried(state)
292
- }, {
293
- ok: false,
294
- failure: {
295
- fate: response.type,
296
- cause: "server"
297
- }
298
- });
162
+ const handleCmd = (s, cmd) => {
163
+ if (typeof cmd.seq !== "number") protocolError("cmd entry missing seq");
164
+ if (cmd.dur === true) {
165
+ s.durableSeq = Math.max(s.durableSeq, cmd.seq);
166
+ for (const entry of s.pending) if (entry.seq <= s.durableSeq) entry.posted = true;
167
+ }
168
+ if (cmd.type === void 0) return;
169
+ const entry = s.pending.find((p) => p.seq === cmd.seq);
170
+ if (cmd.type === "applied") {
171
+ if (entry) cover(s, entry);
172
+ return;
299
173
  }
174
+ if (!entry) return;
175
+ cover(s, entry);
176
+ const outcome = cmd.type === "result" ? {
177
+ ok: true,
178
+ payload: reviveResponse(cmd).payload
179
+ } : cmd.type === "rejected" ? {
180
+ ok: false,
181
+ failure: rejectedFailure(reviveResponse(cmd))
182
+ } : cmd.type === "failed" ? {
183
+ ok: false,
184
+ failure: {
185
+ fate: "failed",
186
+ cause: "server",
187
+ ...cmd.message !== void 0 && { message: cmd.message }
188
+ }
189
+ } : cmd.type === "crashed" ? {
190
+ ok: false,
191
+ failure: {
192
+ fate: "crashed",
193
+ cause: "server"
194
+ }
195
+ } : cmd.type === "expired" ? {
196
+ ok: false,
197
+ failure: {
198
+ fate: "expired",
199
+ cause: "server"
200
+ }
201
+ } : protocolError(`unknown cmd type: ${String(cmd.type)}`);
202
+ settleEntry(s, entry, outcome);
203
+ };
204
+ const handleCmds = (s, cmds) => {
205
+ for (const cmd of cmds) handleCmd(s, cmd);
206
+ wake(s);
300
207
  };
301
208
  const ingestOps = (s, ops) => {
302
209
  const applied = applyOps(s.canonical, ops);
303
210
  s.canonical = applied.state;
304
211
  s.changed.push(...applied.changedPaths);
305
- return applied.events;
306
212
  };
307
- const publishIngested = (s, events) => {
213
+ const publishIngested = (s) => {
308
214
  publish(s);
309
- for (const event of events) s.emitEvent(event);
310
215
  wake(s);
311
216
  };
312
- const handleSnapshot = (s, { ops, lastSeq }) => {
313
- if (lastSeq === -1 && s.attach !== "detached") {
314
- evict(s);
315
- s.transport?.connection.reconnect();
316
- publishIngested(s, ingestOps(s, ops));
317
- return;
217
+ const RECORDS = [
218
+ "fresh",
219
+ "resumed",
220
+ "ephemeral"
221
+ ];
222
+ const snapClocks = (s, seqWm, ctxWm) => {
223
+ const tail = s.pending.filter((p) => p.seq > seqWm);
224
+ if (!(tail.every((p, i) => p.seq === seqWm + 1 + i) && s.record.nextSeq === seqWm + tail.length + 1)) {
225
+ s.pending = s.pending.filter((p) => p.seq <= seqWm);
226
+ setRecord(s, {
227
+ ...s.record,
228
+ nextSeq: seqWm + 1
229
+ });
230
+ for (const old of tail) {
231
+ s.lifecycle?.onCovered?.(old.inv);
232
+ enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
233
+ }
234
+ }
235
+ if (!(s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) && s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1)) {
236
+ setRecord(s, {
237
+ ...s.record,
238
+ ctxNextSeq: ctxWm + 1
239
+ });
240
+ restateContext(s);
318
241
  }
319
- const events = ingestOps(s, ops);
242
+ };
243
+ const handleHello = (s, { ops, syn }) => {
244
+ const record = syn.record ?? "fresh";
245
+ if (!RECORDS.includes(record)) protocolError(`unknown record: ${String(record)}`);
246
+ ingestOps(s, ops);
320
247
  s.attach = "attached";
321
248
  s.attachEpoch += 1;
322
- noteAcked(s, lastSeq);
323
- spliceAcked(s, lastSeq, false);
324
- s.lastAck = lastSeq;
325
- for (const entry of s.pending) {
326
- const state = entry.state;
327
- if (entry.seq <= lastSeq) {
328
- if (state.at === "awaiting-result" && state.posted) transition(s, entry, {
329
- ...state,
330
- posted: false
331
- });
332
- if (state.at === "awaiting-result") pump(s);
333
- } else if (state.at === "posted") {
334
- transition(s, entry, {
335
- at: "trying",
336
- pendingMarked: state.pendingMarked,
337
- failedAttempts: state.failedAttempts
338
- });
339
- pump(s);
340
- } else if (state.at === "settled-live" && state.posted) {
341
- transition(s, entry, {
342
- ...state,
343
- posted: false
344
- });
345
- pump(s);
346
- }
347
- }
348
- publishIngested(s, events);
249
+ s.frameFailures = 0;
250
+ s.resumeAt = 0;
251
+ s.ephemeral = record === "ephemeral";
252
+ const [attachCmd, attachCtx] = s.attachLaneSeqs;
253
+ s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
254
+ if (record === "resumed") {
255
+ for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
256
+ const ctxWatermark = syn.ctxSeq ?? 0;
257
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
258
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
259
+ snapClocks(s, syn.seq ?? 0, ctxWatermark);
260
+ } else resetRecord(s, s.record.clientId);
261
+ s.sessionKnown = true;
262
+ publishIngested(s);
263
+ pump(s);
349
264
  };
350
- const handleOps = (s, { ops, res, ack }) => {
351
- const horizon = ack === void 0 ? s.lastAck : Math.max(s.lastAck, ack);
352
- if (res !== void 0) {
353
- for (const response of res) if (response.seq > horizon) protocolError("response seq beyond the ack");
265
+ const handleOps = (s, { ops, cmds }) => {
266
+ ingestOps(s, ops);
267
+ for (const cmd of cmds) handleCmd(s, cmd);
268
+ publishIngested(s);
269
+ };
270
+ const handleRefusal = (s, syn) => {
271
+ const { seq, ctxSeq } = syn;
272
+ if (typeof seq === "number") {
273
+ const watermark = Math.max(seq, s.durableSeq);
274
+ for (const entry of s.pending) entry.posted = entry.seq <= watermark;
354
275
  }
355
- const events = ingestOps(s, ops);
356
- if (ack !== void 0) noteAcked(s, ack);
357
- if (res !== void 0) for (const response of res) applyResponse(s, response);
358
- if (ack !== void 0) {
359
- spliceAcked(s, ack, true);
360
- s.lastAck = Math.max(s.lastAck, ack);
276
+ if (typeof ctxSeq === "number") {
277
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
278
+ for (const c of s.ctxPending) c.posted = false;
361
279
  }
362
- publishIngested(s, events);
280
+ if (typeof syn.retry === "number") s.resumeAt = Date.now() + Math.max(0, syn.retry);
281
+ const firstCmd = s.pending.find((p) => !p.posted);
282
+ const firstCtx = s.ctxPending.find((c) => !c.posted);
283
+ if (typeof syn.seq === "number" && firstCmd !== void 0 && firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1 || typeof syn.ctxSeq === "number" && firstCtx !== void 0 && firstCtx.seq > syn.ctxSeq + 1) s.transport?.connection.reconnect();
284
+ wake(s);
285
+ pump(s);
286
+ };
287
+ const handleDetach = (s) => {
288
+ if (s.attach === "attached") s.attach = "detached";
289
+ if (s.ephemeral) ephemeralDetach(s);
290
+ wake(s);
363
291
  };
364
292
  const handleFinish = (s, { reason }) => {
365
293
  if (reason === "evicted" || reason === "error") return;
@@ -369,100 +297,98 @@ const handleFinish = (s, { reason }) => {
369
297
  wake(s);
370
298
  };
371
299
  const isFinished = (s) => s.attach === "finished";
372
- const sendable = (p) => p.state.at === "queued" || p.state.at === "trying" || (p.state.at === "awaiting-result" || p.state.at === "settled-live") && !p.state.posted;
300
+ const buildLanes = (s) => {
301
+ const ctxEntries = [];
302
+ for (const entry of s.ctxPending) {
303
+ if (entry.posted) continue;
304
+ if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1).seq + 1) break;
305
+ ctxEntries.push(entry);
306
+ }
307
+ const cmdEntries = [];
308
+ for (const entry of s.pending) {
309
+ if (entry.posted) continue;
310
+ if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1).seq + 1) break;
311
+ cmdEntries.push(entry);
312
+ }
313
+ if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
314
+ return {
315
+ frame: {
316
+ ...ctxEntries.length > 0 && { ctx: ctxEntries.map((c) => ({
317
+ ops: c.ops,
318
+ seq: c.seq
319
+ })) },
320
+ ...cmdEntries.length > 0 && { cmd: cmdEntries.map((p) => ({
321
+ ...p.command,
322
+ seq: p.seq
323
+ })) }
324
+ },
325
+ cmdEntries,
326
+ ctxEntries
327
+ };
328
+ };
329
+ const takeAttachLanes = (s) => {
330
+ s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
331
+ const wanted = s.lanesOnAttach;
332
+ s.lanesOnAttach = false;
333
+ if (!wanted || !s.sessionKnown) return null;
334
+ const lanes = buildLanes(s);
335
+ if (!lanes) return null;
336
+ s.attachLaneSeqs = [new Set(lanes.cmdEntries.map((p) => p.seq)), new Set(lanes.ctxEntries.map((c) => c.seq))];
337
+ for (const entry of lanes.cmdEntries) entry.attempted = true;
338
+ return lanes.frame;
339
+ };
373
340
  const pump = async (s) => {
374
341
  if (s.pumping) return;
375
342
  s.pumping = true;
376
343
  try {
377
344
  while (s.running && !isFinished(s)) {
378
- const entry = s.pending.find(sendable);
379
- if (!entry) return;
380
345
  const transport = s.transport;
346
+ if (buildLanes(s) === null) return;
381
347
  if (s.attach !== "attached" || !transport) {
348
+ if (transport?.connection.status === "idle") transport.connection.reconnect();
382
349
  await waitForWake(s);
383
350
  continue;
384
351
  }
385
- const clientId = s.record.clientId;
352
+ const resumeDelay = s.resumeAt - Date.now();
353
+ if (resumeDelay > 0) {
354
+ await sleepUntilWake(s, resumeDelay);
355
+ continue;
356
+ }
357
+ const lanes = buildLanes(s);
358
+ if (!lanes) continue;
386
359
  const epoch = s.attachEpoch;
387
- if (entry.state.at === "queued") transition(s, entry, {
388
- at: "trying",
389
- pendingMarked: entry.state.pendingMarked,
390
- failedAttempts: 0
391
- });
360
+ for (const entry of lanes.cmdEntries) entry.attempted = true;
392
361
  let result;
393
362
  try {
394
- result = await transport.send({
395
- seq: entry.seq,
396
- command: entry.command
397
- });
398
- } catch (error) {
363
+ result = await transport.send(lanes.frame);
364
+ } catch {
399
365
  if (!s.running || isFinished(s)) return;
400
- if (!s.pending.includes(entry)) continue;
401
- const state = inFlight(entry.state);
402
- transition(s, entry, {
403
- ...state,
404
- failedAttempts: state.failedAttempts + 1
405
- });
406
- await sleepUntilWake(s, retryDelay(error, state.failedAttempts + 1));
366
+ s.frameFailures += 1;
367
+ await sleepUntilWake(s, backoff(s.frameFailures));
407
368
  continue;
408
369
  }
409
370
  if (!s.running) return;
410
- if (!s.pending.includes(entry)) continue;
411
- if (result.type === "receipt") {
412
- const response = result.response;
413
- const state = entry.state;
414
- if (state.at === "trying" || state.at === "posted") transition(s, entry, {
415
- at: "posted",
416
- pendingMarked: state.pendingMarked,
417
- failedAttempts: state.failedAttempts
418
- });
419
- else if (state.at === "awaiting-result" || state.at === "settled-live") transition(s, entry, {
420
- ...state,
421
- posted: true
422
- });
423
- if (entry.inv) s.lifecycle?.onPosted?.(entry.inv);
424
- if (response !== void 0) {
425
- applyResponse(s, response);
426
- publish(s);
427
- }
428
- } else if (result.type === "rejected") {
429
- transition(s, entry, { at: "done" }, {
430
- ok: false,
431
- failure: {
432
- fate: "rejected",
433
- cause: "server",
434
- message: result.message
371
+ s.frameFailures = 0;
372
+ if (result.type === "ok") {
373
+ if (epoch === s.attachEpoch) {
374
+ for (const entry of lanes.cmdEntries) if (s.pending.includes(entry)) {
375
+ entry.posted = true;
376
+ s.lifecycle?.onPosted?.(entry.inv);
435
377
  }
436
- });
437
- publish(s);
378
+ for (const entry of lanes.ctxEntries) entry.posted = true;
379
+ }
380
+ } else if (result.type === "refusal") {
381
+ if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
438
382
  } else if (result.type === "stale-lease") {
439
- if (epoch === s.attachEpoch) await waitForWake(s);
440
- } else if (result.type === "unknown-client") {
441
383
  if (epoch === s.attachEpoch) {
442
- const state = entry.state;
443
- if (state.at === "trying" && state.failedAttempts === 0) transition(s, entry, {
444
- at: "queued",
445
- pendingMarked: state.pendingMarked
446
- });
447
- s.transport?.connection.reconnect();
448
- while (s.running && !isFinished(s) && epoch === s.attachEpoch && clientId === s.record.clientId) await waitForWake(s);
384
+ transport.connection.reconnect();
385
+ await waitForWake(s);
449
386
  }
450
- } else if (result.type === "seq-gap") {
451
- if (clientId === s.record.clientId) {
452
- console.error(`statewire: server reports a seq gap for seq ${entry.seq}; treating the session as corrupt and rotating the client id`);
453
- const state = inFlight(entry.state);
454
- const failure = state.at === "awaiting-result" ? {
455
- fate: "result-unavailable",
456
- cause: "session-lost"
457
- } : {
458
- fate: state.failedAttempts > 0 ? "lost" : "aborted",
459
- cause: "session-lost"
460
- };
461
- evict(s, {
462
- entry,
463
- failure
464
- });
465
- s.transport?.connection.reconnect();
387
+ } else if (result.type === "no-lease") {
388
+ if (epoch === s.attachEpoch) {
389
+ s.lanesOnAttach = true;
390
+ transport.connection.reconnect();
391
+ while (s.running && !isFinished(s) && epoch === s.attachEpoch) await waitForWake(s);
466
392
  }
467
393
  }
468
394
  }
@@ -488,35 +414,33 @@ const setTransport = (s, transport) => {
488
414
  s.transport = transport;
489
415
  wake(s);
490
416
  };
491
- const enqueue = (s, command, resolve, reject) => {
417
+ const enqueue = (s, command, params, resolve, reject) => {
492
418
  const seq = s.record.nextSeq;
493
- s.record = {
419
+ setRecord(s, {
494
420
  ...s.record,
495
421
  nextSeq: seq + 1
496
- };
497
- s.store.save(s.record);
422
+ });
498
423
  const entry = {
499
424
  seq,
500
425
  command,
501
- inv: command.method === void 0 ? null : {
426
+ inv: {
502
427
  method: command.method,
503
- params: command.params,
428
+ params,
504
429
  ts: Date.now(),
505
430
  seq,
506
431
  clientId: s.record.clientId
507
432
  },
508
- state: {
509
- at: "queued",
510
- pendingMarked: false
511
- },
433
+ attempted: false,
434
+ posted: false,
435
+ applied: false,
512
436
  resolve,
513
437
  reject
514
438
  };
515
439
  s.pending.push(entry);
516
- if (entry.inv) s.lifecycle?.onStart(entry.inv);
440
+ s.lifecycle?.onStart(entry.inv);
517
441
  pump(s);
518
442
  };
519
- const sendCommand = (s, command) => {
443
+ const sendCommand = (s, method, params) => {
520
444
  if (!s.running) return rejectedSend({
521
445
  fate: "aborted",
522
446
  cause: "unmount"
@@ -526,24 +450,107 @@ const sendCommand = (s, command) => {
526
450
  cause: "stream-gone"
527
451
  });
528
452
  const result = new Promise((resolve, reject) => {
529
- enqueue(s, command, resolve, reject);
453
+ const { params: encoded, meta } = encodeParams(params);
454
+ enqueue(s, {
455
+ method,
456
+ params: encoded,
457
+ ...meta !== void 0 && { meta }
458
+ }, params, resolve, reject);
530
459
  });
531
460
  result.catch(() => {});
532
461
  return result;
533
462
  };
534
463
  const setContext = (s, value) => {
535
464
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("statewire: context must be a plain object");
536
- s.context = value;
537
- return s.send({ context: value });
465
+ const { value: encoded, meta } = encodeValue(value);
466
+ s.ctxOps = [{
467
+ op: "replace",
468
+ path: [],
469
+ value: encoded,
470
+ ...meta !== void 0 && { meta }
471
+ }];
472
+ const seq = s.record.ctxNextSeq;
473
+ setRecord(s, {
474
+ ...s.record,
475
+ ctxNextSeq: seq + 1
476
+ });
477
+ s.ctxPending.push({
478
+ seq,
479
+ ops: s.ctxOps,
480
+ posted: false
481
+ });
482
+ wake(s);
483
+ pump(s);
538
484
  };
539
- const useStatewireSession = (options, emit) => {
540
- const [s] = useState(() => createSession(options));
541
- useEffect(() => {
542
- s.emitEvent = emit;
543
- return () => {
544
- s.emitEvent = () => {};
485
+ const useStatewireSession = (options) => {
486
+ const [defaultStore] = useState(createMemorySessionStore);
487
+ const store = options.sessionStore ?? defaultStore;
488
+ const [s] = useState(() => {
489
+ const stored = store.load();
490
+ const session = {
491
+ record: stored ?? {
492
+ clientId: newClientId(),
493
+ nextSeq: 1,
494
+ ctxNextSeq: 1
495
+ },
496
+ ctx: {
497
+ get clientId() {
498
+ return session.record.clientId;
499
+ },
500
+ takeAttachLanes: () => takeAttachLanes(session),
501
+ onHello: (args) => handleHello(session, args),
502
+ onOps: (args_0) => handleOps(session, args_0),
503
+ onCmds: (cmds) => handleCmds(session, cmds),
504
+ onRefusal: (syn) => handleRefusal(session, syn),
505
+ onIdle: (level) => setIdle(session, level),
506
+ onDetach: () => handleDetach(session),
507
+ onFinish: (args_1) => handleFinish(session, args_1)
508
+ },
509
+ recordPersisted: stored !== null,
510
+ store,
511
+ ephemeral: false,
512
+ sessionKnown: false,
513
+ durableSeq: 0,
514
+ resumeAt: 0,
515
+ idle: false,
516
+ onDone: void 0,
517
+ ctxOps: void 0,
518
+ ctxPending: [],
519
+ lanesOnAttach: false,
520
+ attachLaneSeqs: [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()],
521
+ frameFailures: 0,
522
+ pending: [],
523
+ canonical: void 0,
524
+ lastPublished: void 0,
525
+ changed: [],
526
+ source: {
527
+ snapshot: () => session.canonical,
528
+ takeChanged: () => {
529
+ const changed = session.changed;
530
+ session.changed = [];
531
+ return changed;
532
+ },
533
+ setOnChange: (listener) => {
534
+ session.notify = listener ?? (() => {});
535
+ }
536
+ },
537
+ attach: "detached",
538
+ attachEpoch: 0,
539
+ running: false,
540
+ pumping: false,
541
+ transport: null,
542
+ waiters: [],
543
+ lifecycle: null,
544
+ notify: () => {},
545
+ commands: createMethodProxy((method, params) => sendCommand(session, method, params))
545
546
  };
546
- }, [s, emit]);
547
+ bindCommandsHost(session.commands, { setListener: (listener_0) => {
548
+ session.lifecycle = listener_0;
549
+ } });
550
+ return session;
551
+ });
552
+ s.store = store;
553
+ s.onDone = options.onDone;
547
554
  useEffect(() => {
548
555
  start(s);
549
556
  return () => stop(s);