statewire 0.10.0 → 0.11.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/dist/client/session.d.ts +6 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +213 -64
- package/dist/client/session.js.map +1 -1
- package/dist/statewire.d.ts +15 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/package.json +1 -1
- package/src/client/session.ts +275 -63
- package/src/statewire.ts +16 -0
package/src/client/session.ts
CHANGED
|
@@ -73,11 +73,18 @@ export type StatewireSessionOptions = {
|
|
|
73
73
|
onDone?: (() => void) | undefined;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
type Shared = {
|
|
77
|
+
txq: Promise<void>;
|
|
78
|
+
parked: StatewireClient.SessionVerdict[];
|
|
79
|
+
unsubscribe: (() => void) | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
76
82
|
type Session = {
|
|
77
83
|
record: StatewireClient.SessionRecord;
|
|
78
84
|
ctx: StatewireRemoteProps;
|
|
79
85
|
recordPersisted: boolean;
|
|
80
86
|
store: StatewireClient.SessionStorage;
|
|
87
|
+
shared: Shared | false;
|
|
81
88
|
ephemeral: boolean;
|
|
82
89
|
/** An ephemeral record died with its attach: the storage is off for good. */
|
|
83
90
|
deactivated: boolean;
|
|
@@ -144,15 +151,68 @@ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
|
|
|
144
151
|
.filter((p) => p.seq > s.durableSeq)
|
|
145
152
|
.map((p) => ({ seq: p.seq, ...p.command }));
|
|
146
153
|
|
|
154
|
+
const mergeBacklog = (s: Session): StatewireClient.SessionCommand[] => {
|
|
155
|
+
const own = backlog(s);
|
|
156
|
+
if (!s.shared) return own;
|
|
157
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
158
|
+
const foreign = s.record.commands.filter(
|
|
159
|
+
(c) => !known.has(c.seq) && c.seq > s.durableSeq,
|
|
160
|
+
);
|
|
161
|
+
return [...foreign, ...own].sort((a, b) => a.seq - b.seq);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const MAX_PARKED_VERDICTS = 64;
|
|
165
|
+
|
|
147
166
|
// Storage holds what a reload must resend: a command leaves at its dur, at a
|
|
148
167
|
// server verdict, or at a record reset — never at a local channel death.
|
|
149
|
-
const
|
|
150
|
-
const
|
|
168
|
+
const syncBacklogInner = (s: Session) => {
|
|
169
|
+
const parked = s.shared ? s.shared.parked.splice(0) : [];
|
|
170
|
+
const parkedSeqs = new Set(parked.map((v) => v.seq));
|
|
171
|
+
const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
|
|
172
|
+
const verdicts =
|
|
173
|
+
parked.length > 0
|
|
174
|
+
? [...(s.record.verdicts ?? []), ...parked].slice(-MAX_PARKED_VERDICTS)
|
|
175
|
+
: s.record.verdicts;
|
|
151
176
|
const unchanged =
|
|
177
|
+
verdicts === s.record.verdicts &&
|
|
152
178
|
commands.length === s.record.commands.length &&
|
|
153
179
|
commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
|
|
154
180
|
if (unchanged) return;
|
|
155
|
-
setRecord(s, {
|
|
181
|
+
setRecord(s, {
|
|
182
|
+
...s.record,
|
|
183
|
+
commands,
|
|
184
|
+
...(verdicts !== undefined && { verdicts }),
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const syncBacklog = (s: Session) => {
|
|
189
|
+
if (!s.shared) return syncBacklogInner(s);
|
|
190
|
+
void transact(s, () => syncBacklogInner(s));
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Every record mutation over shared storage is a locked read-modify-write.
|
|
194
|
+
const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
195
|
+
if (!s.shared || s.deactivated) {
|
|
196
|
+
fn();
|
|
197
|
+
return Promise.resolve();
|
|
198
|
+
}
|
|
199
|
+
const shared = s.shared;
|
|
200
|
+
const run = shared.txq.then(() =>
|
|
201
|
+
s.store.lock!(async () => {
|
|
202
|
+
const stored = s.store.load();
|
|
203
|
+
if (stored !== null && stored.clientId !== s.record.clientId) {
|
|
204
|
+
adoptReset(s, stored);
|
|
205
|
+
} else if (stored !== null) {
|
|
206
|
+
s.record = stored;
|
|
207
|
+
}
|
|
208
|
+
fn();
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
shared.txq = run.then(
|
|
212
|
+
() => {},
|
|
213
|
+
() => {},
|
|
214
|
+
);
|
|
215
|
+
return run;
|
|
156
216
|
};
|
|
157
217
|
|
|
158
218
|
const setIdle = (s: Session, level: boolean) => {
|
|
@@ -248,11 +308,40 @@ const resetRecord = (s: Session, clientId: string) => {
|
|
|
248
308
|
setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
|
|
249
309
|
for (const old of survivors) {
|
|
250
310
|
s.lifecycle?.onCovered?.(old.inv);
|
|
251
|
-
|
|
311
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
312
|
+
}
|
|
313
|
+
restateContext(s);
|
|
314
|
+
publish(s);
|
|
315
|
+
wake(s);
|
|
316
|
+
void pump(s);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Another sharer replaced the record's identity: their reset settles our
|
|
320
|
+
// attempted history and our survivors re-stamp on the stored clocks.
|
|
321
|
+
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
|
|
322
|
+
const survivors: Pending[] = [];
|
|
323
|
+
for (const entry of [...s.pending]) {
|
|
324
|
+
if (entry.attempted || entry.applied) {
|
|
325
|
+
s.lifecycle?.onCovered?.(entry.inv);
|
|
326
|
+
settleEntry(s, entry, {
|
|
327
|
+
ok: false,
|
|
328
|
+
failure: { fate: "lost", cause: "session-lost" },
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
survivors.push(entry);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
s.pending = [];
|
|
335
|
+
s.durableSeq = 0;
|
|
336
|
+
s.record = stored;
|
|
337
|
+
for (const old of survivors) {
|
|
338
|
+
s.lifecycle?.onCovered?.(old.inv);
|
|
339
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
252
340
|
}
|
|
253
341
|
restateContext(s);
|
|
254
342
|
publish(s);
|
|
255
343
|
wake(s);
|
|
344
|
+
void pump(s);
|
|
256
345
|
};
|
|
257
346
|
|
|
258
347
|
// The record died with the attach: mint a fresh id for the next one.
|
|
@@ -272,6 +361,59 @@ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
|
|
|
272
361
|
};
|
|
273
362
|
};
|
|
274
363
|
|
|
364
|
+
const outcomeOf = (
|
|
365
|
+
cmd: StatewireCmd,
|
|
366
|
+
):
|
|
367
|
+
| { ok: true; payload: unknown }
|
|
368
|
+
| { ok: false; failure: StatewireClient.SendFailure } =>
|
|
369
|
+
cmd.type === "result"
|
|
370
|
+
? { ok: true as const, payload: reviveResponse(cmd).payload }
|
|
371
|
+
: cmd.type === "rejected"
|
|
372
|
+
? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
|
|
373
|
+
: cmd.type === "failed"
|
|
374
|
+
? {
|
|
375
|
+
ok: false as const,
|
|
376
|
+
failure: {
|
|
377
|
+
fate: "failed" as const,
|
|
378
|
+
cause: "server" as const,
|
|
379
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
: cmd.type === "crashed"
|
|
383
|
+
? {
|
|
384
|
+
ok: false as const,
|
|
385
|
+
failure: { fate: "crashed" as const, cause: "server" as const },
|
|
386
|
+
}
|
|
387
|
+
: cmd.type === "expired"
|
|
388
|
+
? {
|
|
389
|
+
ok: false as const,
|
|
390
|
+
failure: {
|
|
391
|
+
fate: "expired" as const,
|
|
392
|
+
cause: "server" as const,
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
: protocolError(`unknown cmd type: ${String(cmd.type)}`);
|
|
396
|
+
|
|
397
|
+
const TERMINAL_CMD_TYPES = new Set([
|
|
398
|
+
"result",
|
|
399
|
+
"rejected",
|
|
400
|
+
"failed",
|
|
401
|
+
"crashed",
|
|
402
|
+
"expired",
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
const parkVerdict = (s: Session, cmd: StatewireCmd) => {
|
|
406
|
+
if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
|
|
407
|
+
s.shared.parked.push({
|
|
408
|
+
seq: cmd.seq,
|
|
409
|
+
type: cmd.type as StatewireClient.SessionVerdict["type"],
|
|
410
|
+
...(cmd.code !== undefined && { code: cmd.code }),
|
|
411
|
+
...(cmd.message !== undefined && { message: cmd.message }),
|
|
412
|
+
...(cmd.payload !== undefined && { payload: cmd.payload }),
|
|
413
|
+
...(cmd.meta !== undefined && { meta: cmd.meta }),
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
|
|
275
417
|
const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
276
418
|
if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
|
|
277
419
|
protocolError("cmd.seq must be a positive safe integer");
|
|
@@ -288,37 +430,30 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
|
|
|
288
430
|
if (entry) cover(s, entry);
|
|
289
431
|
return;
|
|
290
432
|
}
|
|
433
|
+
parkVerdict(s, cmd);
|
|
291
434
|
if (!entry) return;
|
|
292
435
|
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);
|
|
436
|
+
settleEntry(s, entry, outcomeOf(cmd));
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const consumeVerdicts = (s: Session) => {
|
|
440
|
+
const verdicts = s.record.verdicts ?? [];
|
|
441
|
+
const consumed = new Set<number>();
|
|
442
|
+
for (const verdict of verdicts) {
|
|
443
|
+
const entry = s.pending.find((p) => p.seq === verdict.seq);
|
|
444
|
+
if (!entry) continue;
|
|
445
|
+
consumed.add(verdict.seq);
|
|
446
|
+
cover(s, entry);
|
|
447
|
+
settleEntry(s, entry, outcomeOf(verdict as StatewireCmd));
|
|
448
|
+
}
|
|
449
|
+
if (consumed.size === 0) return;
|
|
450
|
+
setRecord(s, {
|
|
451
|
+
...s.record,
|
|
452
|
+
commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
|
|
453
|
+
verdicts: verdicts.filter((v) => !consumed.has(v.seq)),
|
|
454
|
+
});
|
|
455
|
+
publish(s);
|
|
456
|
+
wake(s);
|
|
322
457
|
};
|
|
323
458
|
|
|
324
459
|
const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
|
|
@@ -353,7 +488,7 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
353
488
|
setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
|
|
354
489
|
for (const old of tail) {
|
|
355
490
|
s.lifecycle?.onCovered?.(old.inv);
|
|
356
|
-
|
|
491
|
+
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
357
492
|
}
|
|
358
493
|
}
|
|
359
494
|
const ctxAligned =
|
|
@@ -382,23 +517,34 @@ const handleHello = (
|
|
|
382
517
|
if (s.ephemeral) s.deactivated = true;
|
|
383
518
|
const [attachCmd, attachCtx] = s.attachLaneSeqs;
|
|
384
519
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
520
|
+
const reconcile = () => {
|
|
521
|
+
if (record === "resumed") {
|
|
522
|
+
if (s.shared) adoptStored(s);
|
|
523
|
+
// Resending unsettled statements is exactly-once-safe: absorbed replays
|
|
524
|
+
// re-emit their stored verdicts (applied while still in flight).
|
|
525
|
+
for (const entry of s.pending) {
|
|
526
|
+
entry.posted = attachCmd.has(entry.seq);
|
|
527
|
+
}
|
|
528
|
+
const ctxWatermark = syn.ctxSeq ?? 0;
|
|
529
|
+
s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
|
|
530
|
+
for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
|
|
531
|
+
snapClocks(s, syn.seq ?? 0, ctxWatermark);
|
|
532
|
+
syncBacklogInner(s);
|
|
533
|
+
} else {
|
|
534
|
+
resetRecord(s, s.record.clientId);
|
|
390
535
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
536
|
+
s.sessionKnown = true;
|
|
537
|
+
};
|
|
538
|
+
if (!s.shared || s.deactivated) {
|
|
539
|
+
reconcile();
|
|
540
|
+
publishIngested(s);
|
|
541
|
+
void pump(s);
|
|
542
|
+
return;
|
|
398
543
|
}
|
|
399
|
-
s.
|
|
400
|
-
|
|
401
|
-
|
|
544
|
+
void transact(s, reconcile).then(() => {
|
|
545
|
+
publishIngested(s);
|
|
546
|
+
void pump(s);
|
|
547
|
+
});
|
|
402
548
|
};
|
|
403
549
|
|
|
404
550
|
const handleOps = (
|
|
@@ -451,7 +597,11 @@ const handleDetach = (s: Session) => {
|
|
|
451
597
|
const handleFinish = (s: Session, { reason }: { reason: string }) => {
|
|
452
598
|
if (reason === "evicted" || reason === "error") return;
|
|
453
599
|
s.attach = "finished";
|
|
454
|
-
|
|
600
|
+
// Superseded over shared storage: the superseder resends our backlog and
|
|
601
|
+
// parks the verdicts; our promises settle from the observed record.
|
|
602
|
+
if (!(s.shared && reason === "superseded")) {
|
|
603
|
+
settleAll(s, reason === "superseded" ? "superseded" : "gone");
|
|
604
|
+
}
|
|
455
605
|
publish(s);
|
|
456
606
|
wake(s);
|
|
457
607
|
};
|
|
@@ -536,7 +686,12 @@ const pump = async (s: Session) => {
|
|
|
536
686
|
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
537
687
|
let result;
|
|
538
688
|
try {
|
|
539
|
-
|
|
689
|
+
// Shared storage: the lock across the send keeps per-lane arrival
|
|
690
|
+
// ordered across sharers during attach handover.
|
|
691
|
+
const send = () => transport.send(lanes.frame);
|
|
692
|
+
result = await (s.shared && !s.deactivated
|
|
693
|
+
? s.store.lock!(send)
|
|
694
|
+
: send());
|
|
540
695
|
} catch {
|
|
541
696
|
if (!s.running || isFinished(s)) return;
|
|
542
697
|
s.frameFailures += 1;
|
|
@@ -580,17 +735,34 @@ const pump = async (s: Session) => {
|
|
|
580
735
|
};
|
|
581
736
|
|
|
582
737
|
const start = (s: Session) => {
|
|
738
|
+
if (s.shared) {
|
|
739
|
+
s.shared.unsubscribe = s.store.subscribe!(() => {
|
|
740
|
+
void transact(s, () => consumeVerdicts(s));
|
|
741
|
+
});
|
|
742
|
+
s.running = true;
|
|
743
|
+
void transact(s, () => {
|
|
744
|
+
if (s.store.load() === null) s.store.save(s.record);
|
|
745
|
+
s.recordPersisted = true;
|
|
746
|
+
adoptStored(s);
|
|
747
|
+
consumeVerdicts(s);
|
|
748
|
+
}).then(() => void pump(s));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
583
751
|
if (!s.recordPersisted) {
|
|
584
752
|
s.recordPersisted = true;
|
|
585
753
|
s.store.save(s.record);
|
|
586
754
|
}
|
|
587
|
-
|
|
755
|
+
adoptStored(s);
|
|
588
756
|
s.running = true;
|
|
589
757
|
void pump(s);
|
|
590
758
|
};
|
|
591
759
|
|
|
592
760
|
const stop = (s: Session) => {
|
|
593
761
|
s.running = false;
|
|
762
|
+
if (s.shared) {
|
|
763
|
+
s.shared.unsubscribe?.();
|
|
764
|
+
s.shared.unsubscribe = null;
|
|
765
|
+
}
|
|
594
766
|
wake(s);
|
|
595
767
|
settleAll(s, "unmount");
|
|
596
768
|
};
|
|
@@ -601,7 +773,7 @@ const setTransport = (s: Session, transport: RemoteLink) => {
|
|
|
601
773
|
wake(s);
|
|
602
774
|
};
|
|
603
775
|
|
|
604
|
-
const
|
|
776
|
+
const enqueueInner = (
|
|
605
777
|
s: Session,
|
|
606
778
|
command: WireCommand,
|
|
607
779
|
params: readonly unknown[],
|
|
@@ -626,15 +798,33 @@ const enqueue = (
|
|
|
626
798
|
reject,
|
|
627
799
|
};
|
|
628
800
|
s.pending.push(entry);
|
|
629
|
-
setRecord(s, { ...s.record, nextSeq: seq + 1, commands:
|
|
801
|
+
setRecord(s, { ...s.record, nextSeq: seq + 1, commands: mergeBacklog(s) });
|
|
630
802
|
s.lifecycle?.onStart(entry.inv);
|
|
631
|
-
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const enqueue = (
|
|
806
|
+
s: Session,
|
|
807
|
+
command: WireCommand,
|
|
808
|
+
params: readonly unknown[],
|
|
809
|
+
resolve: (payload: unknown) => void,
|
|
810
|
+
reject: (error: StatewireSendError) => void,
|
|
811
|
+
) => {
|
|
812
|
+
if (!s.shared) {
|
|
813
|
+
enqueueInner(s, command, params, resolve, reject);
|
|
814
|
+
void pump(s);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
void transact(s, () =>
|
|
818
|
+
enqueueInner(s, command, params, resolve, reject),
|
|
819
|
+
).then(() => void pump(s));
|
|
632
820
|
};
|
|
633
821
|
|
|
634
822
|
// A stored command cannot be told from one that reached the server: attempted
|
|
635
823
|
// keeps a fresh record settling it lost instead of replaying it blind.
|
|
636
|
-
const
|
|
637
|
-
const
|
|
824
|
+
const adoptStored = (s: Session) => {
|
|
825
|
+
const known = new Set(s.pending.map((p) => p.seq));
|
|
826
|
+
const stored = s.record.commands.filter((c) => !known.has(c.seq));
|
|
827
|
+
if (stored.length === 0) return;
|
|
638
828
|
for (const { seq, ...command } of stored) {
|
|
639
829
|
const params =
|
|
640
830
|
command.meta === undefined
|
|
@@ -659,6 +849,7 @@ const resume = (s: Session) => {
|
|
|
659
849
|
s.pending.push(entry);
|
|
660
850
|
s.lifecycle?.onStart(entry.inv);
|
|
661
851
|
}
|
|
852
|
+
s.pending.sort((a, b) => a.seq - b.seq);
|
|
662
853
|
};
|
|
663
854
|
|
|
664
855
|
const sendCommand = (
|
|
@@ -690,8 +881,9 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
690
881
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
691
882
|
throw new Error("statewire: context must be a plain object");
|
|
692
883
|
}
|
|
884
|
+
if (!s.running || isFinished(s)) return;
|
|
693
885
|
const { value: encoded, meta } = encodeValue(value);
|
|
694
|
-
|
|
886
|
+
const ops = [
|
|
695
887
|
{
|
|
696
888
|
op: "replace",
|
|
697
889
|
path: [],
|
|
@@ -699,11 +891,22 @@ const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
|
699
891
|
...(meta !== undefined && { meta }),
|
|
700
892
|
},
|
|
701
893
|
];
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
894
|
+
s.ctxOps = ops;
|
|
895
|
+
const mint = () => {
|
|
896
|
+
const seq = s.record.ctxNextSeq;
|
|
897
|
+
setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
|
|
898
|
+
s.ctxPending.push({ seq, ops, posted: false });
|
|
899
|
+
};
|
|
900
|
+
if (!s.shared) {
|
|
901
|
+
mint();
|
|
902
|
+
wake(s);
|
|
903
|
+
void pump(s);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
void transact(s, mint).then(() => {
|
|
907
|
+
wake(s);
|
|
908
|
+
void pump(s);
|
|
909
|
+
});
|
|
707
910
|
};
|
|
708
911
|
|
|
709
912
|
export const useStatewireSession = (
|
|
@@ -717,6 +920,11 @@ export const useStatewireSession = (
|
|
|
717
920
|
);
|
|
718
921
|
}
|
|
719
922
|
const store = options.sessionStore ?? mounted ?? defaultStore;
|
|
923
|
+
if ((store.lock === undefined) !== (store.subscribe === undefined)) {
|
|
924
|
+
throw new Error(
|
|
925
|
+
"statewire: session storage must provide lock and subscribe together",
|
|
926
|
+
);
|
|
927
|
+
}
|
|
720
928
|
const [s] = useState((): Session => {
|
|
721
929
|
const stored = store.load();
|
|
722
930
|
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
@@ -747,6 +955,10 @@ export const useStatewireSession = (
|
|
|
747
955
|
},
|
|
748
956
|
recordPersisted: stored !== null,
|
|
749
957
|
store,
|
|
958
|
+
shared:
|
|
959
|
+
store.lock !== undefined
|
|
960
|
+
? { txq: Promise.resolve(), parked: [], unsubscribe: null }
|
|
961
|
+
: false,
|
|
750
962
|
ephemeral: false,
|
|
751
963
|
deactivated: false,
|
|
752
964
|
sessionKnown: false,
|
package/src/statewire.ts
CHANGED
|
@@ -261,12 +261,28 @@ export namespace StatewireClient {
|
|
|
261
261
|
nextSeq: number;
|
|
262
262
|
ctxNextSeq: number;
|
|
263
263
|
commands: readonly SessionCommand[];
|
|
264
|
+
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
265
|
+
verdicts?: readonly SessionVerdict[];
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
|
269
|
+
export type SessionVerdict = {
|
|
270
|
+
seq: number;
|
|
271
|
+
type: "result" | "rejected" | "failed" | "crashed" | "expired";
|
|
272
|
+
code?: string;
|
|
273
|
+
message?: string;
|
|
274
|
+
payload?: unknown;
|
|
275
|
+
meta?: unknown;
|
|
264
276
|
};
|
|
265
277
|
|
|
266
278
|
/** Session storage seam; `save` must persist synchronously in the caller's flow. */
|
|
267
279
|
export type SessionStorage = {
|
|
268
280
|
load: () => SessionRecord | null;
|
|
269
281
|
save: (record: SessionRecord) => void;
|
|
282
|
+
/** Serializes record mutations across sharers; presence marks the storage shared. */
|
|
283
|
+
lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
284
|
+
/** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
|
|
285
|
+
subscribe?: (listener: () => void) => () => void;
|
|
270
286
|
};
|
|
271
287
|
|
|
272
288
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|