statewire 0.10.0 → 0.12.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.
- package/README.md +2 -2
- package/dist/client/connection.d.ts +6 -4
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +10 -2
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -1
- package/dist/client/session.d.ts +10 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +246 -97
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +1 -1
- package/dist/host/http.js +4 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +6 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +12 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +2 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +2 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +29 -3
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.js +4 -4
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -1
- package/dist/transports/remote.d.ts +1 -1
- package/dist/transports/remote.js +1 -0
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -2
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.js +2 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/wire/meta.d.ts +5 -3
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +7 -4
- package/dist/wire/meta.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +12 -6
- package/src/client/session.ts +332 -114
- package/src/host/http.ts +10 -9
- package/src/host/socket.ts +18 -28
- package/src/host-internal.ts +2 -1
- package/src/host.ts +0 -1
- package/src/statewire.ts +33 -3
- package/src/transports/fake-sse.ts +6 -6
- package/src/transports/remote.ts +1 -0
- package/src/transports/testing.ts +2 -7
- package/src/wire/meta.ts +12 -4
package/src/client/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useContext, useEffect, useState } from "react";
|
|
2
2
|
import { applyOps } from "../wire/ops";
|
|
3
3
|
import type { StatewirePath } from "../wire/ops";
|
|
4
|
+
import { TERMINAL } from "./connection";
|
|
4
5
|
import type { StatewireCmd, StatewireSyn } from "./connection";
|
|
5
6
|
import {
|
|
6
7
|
encodeParams,
|
|
@@ -70,14 +71,22 @@ type AttachState = "detached" | "attached" | "finished";
|
|
|
70
71
|
|
|
71
72
|
export type StatewireSessionOptions = {
|
|
72
73
|
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
74
|
+
onSessionLost?: StatewireClient.OnSessionLost | undefined;
|
|
73
75
|
onDone?: (() => void) | undefined;
|
|
74
76
|
};
|
|
75
77
|
|
|
78
|
+
type Shared = {
|
|
79
|
+
txq: Promise<void>;
|
|
80
|
+
parked: StatewireClient.SessionVerdict[];
|
|
81
|
+
unsubscribe: (() => void) | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
76
84
|
type Session = {
|
|
77
85
|
record: StatewireClient.SessionRecord;
|
|
78
86
|
ctx: StatewireRemoteProps;
|
|
79
87
|
recordPersisted: boolean;
|
|
80
88
|
store: StatewireClient.SessionStorage;
|
|
89
|
+
shared: Shared | false;
|
|
81
90
|
ephemeral: boolean;
|
|
82
91
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
83
92
|
deactivated: boolean;
|
|
@@ -85,6 +94,7 @@ type Session = {
|
|
|
85
94
|
durableSeq: number;
|
|
86
95
|
resumeAt: number;
|
|
87
96
|
idle: boolean;
|
|
97
|
+
onSessionLost: StatewireClient.OnSessionLost | undefined;
|
|
88
98
|
onDone: (() => void) | undefined;
|
|
89
99
|
ctxOps: unknown[] | undefined;
|
|
90
100
|
ctxPending: CtxPending[];
|
|
@@ -144,15 +154,68 @@ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
|
|
|
144
154
|
.filter((p) => p.seq > s.durableSeq)
|
|
145
155
|
.map((p) => ({ seq: p.seq, ...p.command }));
|
|
146
156
|
|
|
157
|
+
const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
|
|
158
|
+
const own = backlog(s);
|
|
159
|
+
if (!s.shared) return own;
|
|
160
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
161
|
+
const foreign = s.record.commands.filter(
|
|
162
|
+
(c) => !known.has(c.seq) && c.seq > s.durableSeq,
|
|
163
|
+
);
|
|
164
|
+
return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const MAX_PARKED_VERDICTS = 64;
|
|
168
|
+
|
|
147
169
|
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
148
170
|
// server verdict, or at a record reset — never at a local channel death.
|
|
149
|
-
const
|
|
150
|
-
const
|
|
171
|
+
const syncBacklogInner = (s: Session) => {
|
|
172
|
+
const parked = s.shared ? s.shared.parked.splice(0) : [];
|
|
173
|
+
const parkedSeqs = new Set(parked.map((v) => v.seq));
|
|
174
|
+
const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
|
|
175
|
+
const verdicts =
|
|
176
|
+
parked.length > 0
|
|
177
|
+
? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
|
|
178
|
+
: s.record.verdicts;
|
|
151
179
|
const unchanged =
|
|
180
|
+
verdicts === s.record.verdicts &&
|
|
152
181
|
commands.length === s.record.commands.length &&
|
|
153
182
|
commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
|
|
154
183
|
if (unchanged) return;
|
|
155
|
-
setRecord(s, {
|
|
184
|
+
setRecord(s, {
|
|
185
|
+
...s.record,
|
|
186
|
+
commands,
|
|
187
|
+
...(verdicts !== undefined && { verdicts }),
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const syncBacklog = (s: Session) => {
|
|
192
|
+
if (!s.shared) return syncBacklogInner(s);
|
|
193
|
+
void transact(s, () => syncBacklogInner(s));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Every record mutation over shared storage is a locked read-modify-write.
|
|
197
|
+
const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
198
|
+
if (!s.shared || s.deactivated) {
|
|
199
|
+
fn();
|
|
200
|
+
return Promise.resolve();
|
|
201
|
+
}
|
|
202
|
+
const shared = s.shared;
|
|
203
|
+
const run = shared.txq.then(() =>
|
|
204
|
+
s.store.lock!(async () => {
|
|
205
|
+
const stored = s.store.load();
|
|
206
|
+
if (stored !== null && stored.clientId !== s.record.clientId) {
|
|
207
|
+
adoptReset(s, stored);
|
|
208
|
+
} else if (stored !== null) {
|
|
209
|
+
s.record = stored;
|
|
210
|
+
}
|
|
211
|
+
fn();
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
shared.txq = run.then(
|
|
215
|
+
() => {},
|
|
216
|
+
() => {},
|
|
217
|
+
);
|
|
218
|
+
return run;
|
|
156
219
|
};
|
|
157
220
|
|
|
158
221
|
const setIdle = (s: Session, level: boolean) => {
|
|
@@ -192,14 +255,9 @@ const settleEntry = (
|
|
|
192
255
|
}
|
|
193
256
|
};
|
|
194
257
|
|
|
195
|
-
const settleAll = (s: Session, detach: "unmount" | "gone"
|
|
196
|
-
const cause =
|
|
197
|
-
|
|
198
|
-
unmount: "unmount",
|
|
199
|
-
gone: "stream-gone",
|
|
200
|
-
superseded: "superseded",
|
|
201
|
-
} as const
|
|
202
|
-
)[detach];
|
|
258
|
+
const settleAll = (s: Session, detach: "unmount" | "gone") => {
|
|
259
|
+
const cause =
|
|
260
|
+
detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
|
|
203
261
|
const batch = s.pending;
|
|
204
262
|
const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
|
|
205
263
|
if (!entry.attempted) {
|
|
@@ -226,35 +284,52 @@ const restateContext = (s: Session) => {
|
|
|
226
284
|
s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
|
|
227
285
|
};
|
|
228
286
|
|
|
287
|
+
const settleLost = (s: Session, entries: Pending[]) => {
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
290
|
+
settleEntry(s, entry, {
|
|
291
|
+
ok: false,
|
|
292
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
229
297
|
// The server-side record's dedup memory is gone: history that may have
|
|
230
298
|
// reached a server settles lost — the new hello's snapshot is the only
|
|
231
299
|
// truth about what survived — and never-attempted statements re-stamp
|
|
232
|
-
//
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
failure: { fate: "lost", cause: "session-lost" },
|
|
241
|
-
});
|
|
242
|
-
} else {
|
|
243
|
-
survivors.push(entry);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
300
|
+
// on the adopted lane clocks.
|
|
301
|
+
const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
302
|
+
const held = [...s.pending];
|
|
303
|
+
const survivors = held.filter((p) => !p.attempted && !p.applied);
|
|
304
|
+
settleLost(
|
|
305
|
+
s,
|
|
306
|
+
held.filter((p) => p.attempted || p.applied),
|
|
307
|
+
);
|
|
246
308
|
s.pending = [];
|
|
247
309
|
s.durableSeq = 0;
|
|
248
|
-
|
|
310
|
+
adopt();
|
|
249
311
|
for (const old of survivors) {
|
|
250
312
|
s.lifecycle?.onCovered?.(old.inv);
|
|
251
|
-
|
|
313
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
252
314
|
}
|
|
253
315
|
restateContext(s);
|
|
254
316
|
publish(s);
|
|
255
317
|
wake(s);
|
|
318
|
+
void pump(s);
|
|
256
319
|
};
|
|
257
320
|
|
|
321
|
+
const resetRecord = (s: Session, clientId: string) =>
|
|
322
|
+
rebaseRecord(s, () =>
|
|
323
|
+
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Another sharer replaced the record's identity: their reset settles our
|
|
327
|
+
// attempted history and our survivors re-stamp on the stored clocks.
|
|
328
|
+
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
|
|
329
|
+
rebaseRecord(s, () => {
|
|
330
|
+
s.record = stored;
|
|
331
|
+
});
|
|
332
|
+
|
|
258
333
|
// The record died with the attach: mint a fresh id for the next one.
|
|
259
334
|
const ephemeralDetach = (s: Session) => {
|
|
260
335
|
s.ephemeral = false;
|
|
@@ -272,6 +347,48 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
|
272
347
|
};
|
|
273
348
|
};
|
|
274
349
|
|
|
350
|
+
const outcomeOf = (
|
|
351
|
+
cmd: StatewireCmd,
|
|
352
|
+
):
|
|
353
|
+
| { ok: true; payload: unknown }
|
|
354
|
+
| { ok: false; failure: StatewireClient.SendFailure } => {
|
|
355
|
+
switch (cmd.type) {
|
|
356
|
+
case "result":
|
|
357
|
+
return { ok: true, payload: reviveResponse(cmd).payload };
|
|
358
|
+
case "rejected":
|
|
359
|
+
return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
|
|
360
|
+
case "failed":
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
failure: {
|
|
364
|
+
fate: "failed",
|
|
365
|
+
cause: "server",
|
|
366
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
case "crashed":
|
|
370
|
+
return { ok: false, failure: { fate: "crashed", cause: "server" } };
|
|
371
|
+
case "expired":
|
|
372
|
+
return { ok: false, failure: { fate: "expired", cause: "server" } };
|
|
373
|
+
default:
|
|
374
|
+
return protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
|
|
379
|
+
|
|
380
|
+
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
381
|
+
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
382
|
+
s.shared.parked.push({
|
|
383
|
+
seq: cmd.seq,
|
|
384
|
+
type: cmd.type as StatewireClient.SessionVerdict["type"],
|
|
385
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
386
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
387
|
+
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
388
|
+
...(cmd.meta !== undefined && { meta: cmd.meta }),
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
|
|
275
392
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
276
393
|
if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
|
|
277
394
|
protocolError("cmd.seq must be a positive safe integer");
|
|
@@ -288,37 +405,30 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
288
405
|
if (entry) cover(s, entry);
|
|
289
406
|
return;
|
|
290
407
|
}
|
|
408
|
+
parkVerdict(s, cmd);
|
|
291
409
|
if (!entry) return;
|
|
292
410
|
cover(s, entry);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
ok: false as const,
|
|
315
|
-
failure: {
|
|
316
|
-
fate: "expired" as const,
|
|
317
|
-
cause: "server" as const,
|
|
318
|
-
},
|
|
319
|
-
}
|
|
320
|
-
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
321
|
-
settleEntry(s, entry, outcome);
|
|
411
|
+
settleEntry(s, entry, outcomeOf(cmd));
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const consumeVerdicts = (s: Session) => {
|
|
415
|
+
const verdicts = s.record.verdicts ?? [];
|
|
416
|
+
const consumed = new Set<number>();
|
|
417
|
+
for (const verdict of verdicts) {
|
|
418
|
+
const entry = s.pending.find((p) => p.seq === verdict.seq);
|
|
419
|
+
if (!entry) continue;
|
|
420
|
+
consumed.add(verdict.seq);
|
|
421
|
+
cover(s, entry);
|
|
422
|
+
settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
|
|
423
|
+
}
|
|
424
|
+
if (consumed.size === 0) return;
|
|
425
|
+
setRecord(s, {
|
|
426
|
+
...s.record,
|
|
427
|
+
commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
|
|
428
|
+
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
429
|
+
});
|
|
430
|
+
publish(s);
|
|
431
|
+
wake(s);
|
|
322
432
|
};
|
|
323
433
|
|
|
324
434
|
const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
@@ -338,24 +448,10 @@ const publishIngested = (s: Session) => {
|
|
|
338
448
|
wake(s);
|
|
339
449
|
};
|
|
340
450
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
// never-admitted statements consecutively after the watermark.
|
|
346
|
-
const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
347
|
-
const tail = s.pending.filter((p) => p.seq > seqWm);
|
|
348
|
-
const cmdAligned =
|
|
349
|
-
tail.every((p, i) => p.seq === seqWm + 1 + i) &&
|
|
350
|
-
s.record.nextSeq === seqWm + tail.length + 1;
|
|
351
|
-
if (!cmdAligned) {
|
|
352
|
-
s.pending = s.pending.filter((p) => p.seq <= seqWm);
|
|
353
|
-
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
354
|
-
for (const old of tail) {
|
|
355
|
-
s.lifecycle?.onCovered?.(old.inv);
|
|
356
|
-
enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
451
|
+
// A hello's cmd watermark may understate a durable record (cold host, tray
|
|
452
|
+
// unloaded): held seqs resend as-is and the frame's answer arbitrates —
|
|
453
|
+
// absorption where the record survived, a gap refusal where it did not.
|
|
454
|
+
const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
359
455
|
const ctxAligned =
|
|
360
456
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
361
457
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
@@ -365,40 +461,81 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
365
461
|
}
|
|
366
462
|
};
|
|
367
463
|
|
|
464
|
+
// The refusal's watermarks are exact: every held statement above them was
|
|
465
|
+
// never admitted by the live record, and the first unposted seq being beyond
|
|
466
|
+
// watermark+1 proves the record that admitted the missing prefix is gone —
|
|
467
|
+
// its history settles lost and the lane clocks restart at the watermarks.
|
|
468
|
+
const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
469
|
+
const held = [...s.pending];
|
|
470
|
+
settleLost(s, held);
|
|
471
|
+
s.pending = [];
|
|
472
|
+
s.durableSeq = seqWm;
|
|
473
|
+
setRecord(s, {
|
|
474
|
+
clientId: s.record.clientId,
|
|
475
|
+
nextSeq: seqWm + 1,
|
|
476
|
+
ctxNextSeq: ctxWm + 1,
|
|
477
|
+
commands: [],
|
|
478
|
+
});
|
|
479
|
+
restateContext(s);
|
|
480
|
+
if (
|
|
481
|
+
held.length > 0 &&
|
|
482
|
+
s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
|
|
483
|
+
) {
|
|
484
|
+
for (const old of held) {
|
|
485
|
+
enqueueInner(
|
|
486
|
+
s,
|
|
487
|
+
old.command,
|
|
488
|
+
old.inv.params,
|
|
489
|
+
() => {},
|
|
490
|
+
() => {},
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
publish(s);
|
|
495
|
+
wake(s);
|
|
496
|
+
};
|
|
497
|
+
|
|
368
498
|
const handleHello = (
|
|
369
499
|
s: Session,
|
|
370
500
|
{ ops, syn }: { ops: unknown[]; syn: StatewireSyn },
|
|
371
501
|
) => {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
protocolError(`unknown record: ${String(record)}`);
|
|
502
|
+
if (syn.record !== undefined && syn.record !== "ephemeral") {
|
|
503
|
+
protocolError(`unknown record: ${String(syn.record)}`);
|
|
375
504
|
}
|
|
376
505
|
ingestOps(s, ops);
|
|
377
506
|
s.attach = "attached";
|
|
378
507
|
s.attachEpoch += 1;
|
|
379
508
|
s.frameFailures = 0;
|
|
380
509
|
s.resumeAt = 0;
|
|
381
|
-
s.ephemeral = record === "ephemeral";
|
|
510
|
+
s.ephemeral = syn.record === "ephemeral";
|
|
382
511
|
if (s.ephemeral) s.deactivated = true;
|
|
383
512
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
384
513
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
385
|
-
|
|
514
|
+
const reconcile = () => {
|
|
515
|
+
if (s.shared) adoptStored(s);
|
|
386
516
|
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
387
517
|
// re-emit their stored verdicts (applied while still in flight).
|
|
518
|
+
s.durableSeq = syn.seq ?? 0;
|
|
388
519
|
for (const entry of s.pending) {
|
|
389
520
|
entry.posted = attachCmd.has(entry.seq);
|
|
390
521
|
}
|
|
391
522
|
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
392
523
|
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
393
524
|
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
525
|
+
snapCtxClock(s, ctxWatermark);
|
|
526
|
+
syncBacklogInner(s);
|
|
527
|
+
s.sessionKnown = true;
|
|
528
|
+
};
|
|
529
|
+
if (!s.shared || s.deactivated) {
|
|
530
|
+
reconcile();
|
|
531
|
+
publishIngested(s);
|
|
532
|
+
void pump(s);
|
|
533
|
+
return;
|
|
398
534
|
}
|
|
399
|
-
s.
|
|
400
|
-
|
|
401
|
-
|
|
535
|
+
void transact(s, reconcile).then(() => {
|
|
536
|
+
publishIngested(s);
|
|
537
|
+
void pump(s);
|
|
538
|
+
});
|
|
402
539
|
};
|
|
403
540
|
|
|
404
541
|
const handleOps = (
|
|
@@ -411,8 +548,9 @@ const handleOps = (
|
|
|
411
548
|
publishIngested(s);
|
|
412
549
|
};
|
|
413
550
|
|
|
414
|
-
// A {syn} refusal
|
|
415
|
-
//
|
|
551
|
+
// A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
|
|
552
|
+
// against its exact watermark settles the held history lost; a pushback
|
|
553
|
+
// (retry present) or a gapless refusal only reconciles the resend queues.
|
|
416
554
|
const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
417
555
|
const { seq, ctxSeq } = syn;
|
|
418
556
|
if (typeof seq === "number") {
|
|
@@ -425,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
425
563
|
}
|
|
426
564
|
if (typeof syn.retry === "number") {
|
|
427
565
|
s.resumeAt = Date.now() + syn.retry;
|
|
566
|
+
wake(s);
|
|
567
|
+
void pump(s);
|
|
568
|
+
return;
|
|
428
569
|
}
|
|
429
570
|
const firstCmd = s.pending.find((p) => !p.posted);
|
|
430
571
|
const firstCtx = s.ctxPending.find((c) => !c.posted);
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
572
|
+
const cmdGap =
|
|
573
|
+
typeof seq === "number" &&
|
|
574
|
+
firstCmd !== undefined &&
|
|
575
|
+
firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
|
|
576
|
+
const ctxGap =
|
|
577
|
+
typeof ctxSeq === "number" &&
|
|
578
|
+
firstCtx !== undefined &&
|
|
579
|
+
firstCtx.seq > ctxSeq + 1;
|
|
580
|
+
if (cmdGap || ctxGap) {
|
|
581
|
+
void transact(s, () => {
|
|
582
|
+
if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
|
|
583
|
+
else snapCtxClock(s, ctxSeq!);
|
|
584
|
+
}).then(() => {
|
|
585
|
+
wake(s);
|
|
586
|
+
void pump(s);
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
441
590
|
wake(s);
|
|
442
591
|
void pump(s);
|
|
443
592
|
};
|
|
@@ -451,7 +600,7 @@ const handleDetach = (s: Session) => {
|
|
|
451
600
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
452
601
|
if (reason === "evicted" || reason === "error") return;
|
|
453
602
|
s.attach = "finished";
|
|
454
|
-
settleAll(s,
|
|
603
|
+
settleAll(s, "gone");
|
|
455
604
|
publish(s);
|
|
456
605
|
wake(s);
|
|
457
606
|
};
|
|
@@ -536,7 +685,12 @@ const pump = async (s: Session) => {
|
|
|
536
685
|
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
537
686
|
let result;
|
|
538
687
|
try {
|
|
539
|
-
|
|
688
|
+
// Shared storage: the lock across the send keeps per-lane arrival
|
|
689
|
+
// ordered across sharers during attach handover.
|
|
690
|
+
const send = () => transport.send(lanes.frame);
|
|
691
|
+
result = await (s.shared && !s.deactivated
|
|
692
|
+
? s.store.lock!(send)
|
|
693
|
+
: send());
|
|
540
694
|
} catch {
|
|
541
695
|
if (!s.running || isFinished(s)) return;
|
|
542
696
|
s.frameFailures += 1;
|
|
@@ -580,17 +734,34 @@ const pump = async (s: Session) => {
|
|
|
580
734
|
};
|
|
581
735
|
|
|
582
736
|
const start = (s: Session) => {
|
|
737
|
+
if (s.shared) {
|
|
738
|
+
s.shared.unsubscribe = s.store.subscribe!(() => {
|
|
739
|
+
void transact(s, () => consumeVerdicts(s));
|
|
740
|
+
});
|
|
741
|
+
s.running = true;
|
|
742
|
+
void transact(s, () => {
|
|
743
|
+
if (s.store.load() === null) s.store.save(s.record);
|
|
744
|
+
s.recordPersisted = true;
|
|
745
|
+
adoptStored(s);
|
|
746
|
+
consumeVerdicts(s);
|
|
747
|
+
}).then(() => void pump(s));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
583
750
|
if (!s.recordPersisted) {
|
|
584
751
|
s.recordPersisted = true;
|
|
585
752
|
s.store.save(s.record);
|
|
586
753
|
}
|
|
587
|
-
|
|
754
|
+
adoptStored(s);
|
|
588
755
|
s.running = true;
|
|
589
756
|
void pump(s);
|
|
590
757
|
};
|
|
591
758
|
|
|
592
759
|
const stop = (s: Session) => {
|
|
593
760
|
s.running = false;
|
|
761
|
+
if (s.shared) {
|
|
762
|
+
s.shared.unsubscribe?.();
|
|
763
|
+
s.shared.unsubscribe = null;
|
|
764
|
+
}
|
|
594
765
|
wake(s);
|
|
595
766
|
settleAll(s, "unmount");
|
|
596
767
|
};
|
|
@@ -601,7 +772,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
|
|
|
601
772
|
wake(s);
|
|
602
773
|
};
|
|
603
774
|
|
|
604
|
-
const
|
|
775
|
+
const enqueueInner = (
|
|
605
776
|
s: Session,
|
|
606
777
|
command: WireCommand,
|
|
607
778
|
params: readonly unknown[],
|
|
@@ -626,15 +797,33 @@ const enqueue = (
|
|
|
626
797
|
reject,
|
|
627
798
|
};
|
|
628
799
|
s.pending.push(entry);
|
|
629
|
-
setRecord(s, { ...s.record, nextSeq: seq + 1, commands:
|
|
800
|
+
setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
|
|
630
801
|
s.lifecycle?.onStart(entry.inv);
|
|
631
|
-
void pump(s);
|
|
632
802
|
};
|
|
633
803
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
804
|
+
const enqueue = (
|
|
805
|
+
s: Session,
|
|
806
|
+
command: WireCommand,
|
|
807
|
+
params: readonly unknown[],
|
|
808
|
+
resolve: (payload: unknown) => void,
|
|
809
|
+
reject: (error: StatewireSendError) => void,
|
|
810
|
+
) => {
|
|
811
|
+
if (!s.shared) {
|
|
812
|
+
enqueueInner(s, command, params, resolve, reject);
|
|
813
|
+
void pump(s);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
void transact(s, () =>
|
|
817
|
+
enqueueInner(s, command, params, resolve, reject),
|
|
818
|
+
).then(() => void pump(s));
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// A stored command may have reached a server: attempted settles it lost,
|
|
822
|
+
// never aborted, when a channel death ends the session.
|
|
823
|
+
const adoptStored = (s: Session) => {
|
|
824
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
825
|
+
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
826
|
+
if (stored.length === 0) return;
|
|
638
827
|
for (const { seq, ...command } of stored) {
|
|
639
828
|
const params =
|
|
640
829
|
command.meta === undefined
|
|
@@ -659,6 +848,7 @@ const resume = (s: Session) => {
|
|
|
659
848
|
s.pending.push(entry);
|
|
660
849
|
s.lifecycle?.onStart(entry.inv);
|
|
661
850
|
}
|
|
851
|
+
s.pending.sort((a, b) => a.seq - b.seq);
|
|
662
852
|
};
|
|
663
853
|
|
|
664
854
|
const sendCommand = (
|
|
@@ -690,8 +880,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
690
880
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
691
881
|
throw new Error("statewire: context must be a plain object");
|
|
692
882
|
}
|
|
883
|
+
if (!s.running || isFinished(s)) return;
|
|
693
884
|
const { value: encoded, meta } = encodeValue(value);
|
|
694
|
-
|
|
885
|
+
const ops = [
|
|
695
886
|
{
|
|
696
887
|
op: "replace",
|
|
697
888
|
path: [],
|
|
@@ -699,11 +890,22 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
699
890
|
...(meta !== undefined && { meta }),
|
|
700
891
|
},
|
|
701
892
|
];
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
893
|
+
s.ctxOps = ops;
|
|
894
|
+
const mint = () => {
|
|
895
|
+
const seq = s.record.ctxNextSeq;
|
|
896
|
+
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
897
|
+
s.ctxPending.push({ seq, ops, posted: false });
|
|
898
|
+
};
|
|
899
|
+
if (!s.shared) {
|
|
900
|
+
mint();
|
|
901
|
+
wake(s);
|
|
902
|
+
void pump(s);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
void transact(s, mint).then(() => {
|
|
906
|
+
wake(s);
|
|
907
|
+
void pump(s);
|
|
908
|
+
});
|
|
707
909
|
};
|
|
708
910
|
|
|
709
911
|
export const useStatewireSession = (
|
|
@@ -717,6 +919,11 @@ export const useStatewireSession = (
|
|
|
717
919
|
);
|
|
718
920
|
}
|
|
719
921
|
const store = options.sessionStore ?? mounted ?? defaultStore;
|
|
922
|
+
if ((store.lock === undefined) !== (store.subscribe === undefined)) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
"statewire: session storage must provide lock and subscribe together",
|
|
925
|
+
);
|
|
926
|
+
}
|
|
720
927
|
const [s] = useState((): Session => {
|
|
721
928
|
const stored = store.load();
|
|
722
929
|
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
@@ -747,12 +954,17 @@ export const useStatewireSession = (
|
|
|
747
954
|
},
|
|
748
955
|
recordPersisted: stored !== null,
|
|
749
956
|
store,
|
|
957
|
+
shared:
|
|
958
|
+
store.lock !== undefined
|
|
959
|
+
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
960
|
+
: false,
|
|
750
961
|
ephemeral: false,
|
|
751
962
|
deactivated: false,
|
|
752
963
|
sessionKnown: false,
|
|
753
964
|
durableSeq: 0,
|
|
754
965
|
resumeAt: 0,
|
|
755
966
|
idle: false,
|
|
967
|
+
onSessionLost: undefined,
|
|
756
968
|
onDone: undefined,
|
|
757
969
|
ctxOps: undefined,
|
|
758
970
|
ctxPending: [],
|
|
@@ -793,7 +1005,13 @@ export const useStatewireSession = (
|
|
|
793
1005
|
});
|
|
794
1006
|
return session;
|
|
795
1007
|
});
|
|
1008
|
+
if ((store.lock !== undefined) !== (s.shared !== false)) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
"statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
796
1013
|
s.store = store;
|
|
1014
|
+
s.onSessionLost = options.onSessionLost;
|
|
797
1015
|
s.onDone = options.onDone;
|
|
798
1016
|
useEffect(() => {
|
|
799
1017
|
start(s);
|