statewire 0.9.2 → 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.
@@ -2,11 +2,11 @@ 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 { encodeParams, encodeValue, reviveResponse } from "../wire/meta.js";
5
+ import { encodeParams, encodeValue, reviveResponse, reviveValue } from "../wire/meta.js";
6
+ import { SessionStorageContext, createMemorySessionStore } from "./sessionStore.js";
6
7
  import { backoff } from "./timing.js";
7
- import { createMemorySessionStore } from "./sessionStore.js";
8
8
  import { randomId } from "../util/randomId.js";
9
- import { useEffect, useState } from "@assistant-ui/tap/react-shim";
9
+ import { useContext, useEffect, useState } from "@assistant-ui/tap/react-shim";
10
10
  //#region src/client/session.ts
11
11
  const protocolError = (message) => {
12
12
  throw new Error(`statewire: protocol error: ${message}`);
@@ -41,7 +41,48 @@ const sleepUntilWake = (s, delayMs) => new Promise((resolve) => {
41
41
  });
42
42
  const setRecord = (s, record) => {
43
43
  s.record = record;
44
- if (!s.ephemeral) s.store.save(record);
44
+ if (!s.deactivated) s.store.save(record);
45
+ };
46
+ const backlog = (s) => s.pending.filter((p) => p.seq > s.durableSeq).map((p) => ({
47
+ seq: p.seq,
48
+ ...p.command
49
+ }));
50
+ const mergeBacklog = (s) => {
51
+ const own = backlog(s);
52
+ if (!s.shared) return own;
53
+ const known = new Set(s.pending.map((p) => p.seq));
54
+ return [...s.record.commands.filter((c) => !known.has(c.seq) && c.seq > s.durableSeq), ...own].sort((a, b) => a.seq - b.seq);
55
+ };
56
+ const syncBacklogInner = (s) => {
57
+ const parked = s.shared ? s.shared.parked.splice(0) : [];
58
+ const parkedSeqs = new Set(parked.map((v) => v.seq));
59
+ const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
60
+ const verdicts = parked.length > 0 ? [...s.record.verdicts ?? [], ...parked].slice(-64) : s.record.verdicts;
61
+ if (verdicts === s.record.verdicts && commands.length === s.record.commands.length && commands.every((c, i) => c.seq === s.record.commands[i].seq)) return;
62
+ setRecord(s, {
63
+ ...s.record,
64
+ commands,
65
+ ...verdicts !== void 0 && { verdicts }
66
+ });
67
+ };
68
+ const syncBacklog = (s) => {
69
+ if (!s.shared) return syncBacklogInner(s);
70
+ transact(s, () => syncBacklogInner(s));
71
+ };
72
+ const transact = (s, fn) => {
73
+ if (!s.shared || s.deactivated) {
74
+ fn();
75
+ return Promise.resolve();
76
+ }
77
+ const shared = s.shared;
78
+ const run = shared.txq.then(() => s.store.lock(async () => {
79
+ const stored = s.store.load();
80
+ if (stored !== null && stored.clientId !== s.record.clientId) adoptReset(s, stored);
81
+ else if (stored !== null) s.record = stored;
82
+ fn();
83
+ }));
84
+ shared.txq = run.then(() => {}, () => {});
85
+ return run;
45
86
  };
46
87
  const setIdle = (s, level) => {
47
88
  if (level === s.idle) return;
@@ -134,15 +175,41 @@ const resetRecord = (s, clientId) => {
134
175
  setRecord(s, {
135
176
  clientId,
136
177
  nextSeq: 1,
137
- ctxNextSeq: 1
178
+ ctxNextSeq: 1,
179
+ commands: []
138
180
  });
139
181
  for (const old of survivors) {
140
182
  s.lifecycle?.onCovered?.(old.inv);
141
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
183
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
142
184
  }
143
185
  restateContext(s);
144
186
  publish(s);
145
187
  wake(s);
188
+ pump(s);
189
+ };
190
+ const adoptReset = (s, stored) => {
191
+ const survivors = [];
192
+ for (const entry of [...s.pending]) if (entry.attempted || entry.applied) {
193
+ s.lifecycle?.onCovered?.(entry.inv);
194
+ settleEntry(s, entry, {
195
+ ok: false,
196
+ failure: {
197
+ fate: "lost",
198
+ cause: "session-lost"
199
+ }
200
+ });
201
+ } else survivors.push(entry);
202
+ s.pending = [];
203
+ s.durableSeq = 0;
204
+ s.record = stored;
205
+ for (const old of survivors) {
206
+ s.lifecycle?.onCovered?.(old.inv);
207
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
208
+ }
209
+ restateContext(s);
210
+ publish(s);
211
+ wake(s);
212
+ pump(s);
146
213
  };
147
214
  const ephemeralDetach = (s) => {
148
215
  s.ephemeral = false;
@@ -158,6 +225,50 @@ const rejectedFailure = (cmd) => {
158
225
  ...cmd.payload !== void 0 && { payload: cmd.payload }
159
226
  };
160
227
  };
228
+ const outcomeOf = (cmd) => cmd.type === "result" ? {
229
+ ok: true,
230
+ payload: reviveResponse(cmd).payload
231
+ } : cmd.type === "rejected" ? {
232
+ ok: false,
233
+ failure: rejectedFailure(reviveResponse(cmd))
234
+ } : cmd.type === "failed" ? {
235
+ ok: false,
236
+ failure: {
237
+ fate: "failed",
238
+ cause: "server",
239
+ ...cmd.message !== void 0 && { message: cmd.message }
240
+ }
241
+ } : cmd.type === "crashed" ? {
242
+ ok: false,
243
+ failure: {
244
+ fate: "crashed",
245
+ cause: "server"
246
+ }
247
+ } : cmd.type === "expired" ? {
248
+ ok: false,
249
+ failure: {
250
+ fate: "expired",
251
+ cause: "server"
252
+ }
253
+ } : protocolError(`unknown cmd type: ${String(cmd.type)}`);
254
+ const TERMINAL_CMD_TYPES = /* @__PURE__ */ new Set([
255
+ "result",
256
+ "rejected",
257
+ "failed",
258
+ "crashed",
259
+ "expired"
260
+ ]);
261
+ const parkVerdict = (s, cmd) => {
262
+ if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type)) return;
263
+ s.shared.parked.push({
264
+ seq: cmd.seq,
265
+ type: cmd.type,
266
+ ...cmd.code !== void 0 && { code: cmd.code },
267
+ ...cmd.message !== void 0 && { message: cmd.message },
268
+ ...cmd.payload !== void 0 && { payload: cmd.payload },
269
+ ...cmd.meta !== void 0 && { meta: cmd.meta }
270
+ });
271
+ };
161
272
  const handleCmd = (s, cmd) => {
162
273
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) protocolError("cmd.seq must be a positive safe integer");
163
274
  if (cmd.dur === true) {
@@ -170,38 +281,33 @@ const handleCmd = (s, cmd) => {
170
281
  if (entry) cover(s, entry);
171
282
  return;
172
283
  }
284
+ parkVerdict(s, cmd);
173
285
  if (!entry) return;
174
286
  cover(s, entry);
175
- const outcome = cmd.type === "result" ? {
176
- ok: true,
177
- payload: reviveResponse(cmd).payload
178
- } : cmd.type === "rejected" ? {
179
- ok: false,
180
- failure: rejectedFailure(reviveResponse(cmd))
181
- } : cmd.type === "failed" ? {
182
- ok: false,
183
- failure: {
184
- fate: "failed",
185
- cause: "server",
186
- ...cmd.message !== void 0 && { message: cmd.message }
187
- }
188
- } : cmd.type === "crashed" ? {
189
- ok: false,
190
- failure: {
191
- fate: "crashed",
192
- cause: "server"
193
- }
194
- } : cmd.type === "expired" ? {
195
- ok: false,
196
- failure: {
197
- fate: "expired",
198
- cause: "server"
199
- }
200
- } : protocolError(`unknown cmd type: ${String(cmd.type)}`);
201
- settleEntry(s, entry, outcome);
287
+ settleEntry(s, entry, outcomeOf(cmd));
288
+ };
289
+ const consumeVerdicts = (s) => {
290
+ const verdicts = s.record.verdicts ?? [];
291
+ const consumed = /* @__PURE__ */ new Set();
292
+ for (const verdict of verdicts) {
293
+ const entry = s.pending.find((p) => p.seq === verdict.seq);
294
+ if (!entry) continue;
295
+ consumed.add(verdict.seq);
296
+ cover(s, entry);
297
+ settleEntry(s, entry, outcomeOf(verdict));
298
+ }
299
+ if (consumed.size === 0) return;
300
+ setRecord(s, {
301
+ ...s.record,
302
+ commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
303
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq))
304
+ });
305
+ publish(s);
306
+ wake(s);
202
307
  };
203
308
  const handleCmds = (s, cmds) => {
204
309
  for (const cmd of cmds) handleCmd(s, cmd);
310
+ syncBacklog(s);
205
311
  wake(s);
206
312
  };
207
313
  const ingestOps = (s, ops) => {
@@ -228,7 +334,7 @@ const snapClocks = (s, seqWm, ctxWm) => {
228
334
  });
229
335
  for (const old of tail) {
230
336
  s.lifecycle?.onCovered?.(old.inv);
231
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
337
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
232
338
  }
233
339
  }
234
340
  if (!(s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) && s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1)) {
@@ -248,22 +354,36 @@ const handleHello = (s, { ops, syn }) => {
248
354
  s.frameFailures = 0;
249
355
  s.resumeAt = 0;
250
356
  s.ephemeral = record === "ephemeral";
357
+ if (s.ephemeral) s.deactivated = true;
251
358
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
252
359
  s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
253
- if (record === "resumed") {
254
- for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
255
- const ctxWatermark = syn.ctxSeq ?? 0;
256
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
257
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
258
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
259
- } else resetRecord(s, s.record.clientId);
260
- s.sessionKnown = true;
261
- publishIngested(s);
262
- pump(s);
360
+ const reconcile = () => {
361
+ if (record === "resumed") {
362
+ if (s.shared) adoptStored(s);
363
+ for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
364
+ const ctxWatermark = syn.ctxSeq ?? 0;
365
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
366
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
367
+ snapClocks(s, syn.seq ?? 0, ctxWatermark);
368
+ syncBacklogInner(s);
369
+ } else resetRecord(s, s.record.clientId);
370
+ s.sessionKnown = true;
371
+ };
372
+ if (!s.shared || s.deactivated) {
373
+ reconcile();
374
+ publishIngested(s);
375
+ pump(s);
376
+ return;
377
+ }
378
+ transact(s, reconcile).then(() => {
379
+ publishIngested(s);
380
+ pump(s);
381
+ });
263
382
  };
264
383
  const handleOps = (s, { ops, cmds }) => {
265
384
  ingestOps(s, ops);
266
385
  for (const cmd of cmds) handleCmd(s, cmd);
386
+ syncBacklog(s);
267
387
  publishIngested(s);
268
388
  };
269
389
  const handleRefusal = (s, syn) => {
@@ -291,7 +411,7 @@ const handleDetach = (s) => {
291
411
  const handleFinish = (s, { reason }) => {
292
412
  if (reason === "evicted" || reason === "error") return;
293
413
  s.attach = "finished";
294
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
414
+ if (!(s.shared && reason === "superseded")) settleAll(s, reason === "superseded" ? "superseded" : "gone");
295
415
  publish(s);
296
416
  wake(s);
297
417
  };
@@ -359,7 +479,8 @@ const pump = async (s) => {
359
479
  for (const entry of lanes.cmdEntries) entry.attempted = true;
360
480
  let result;
361
481
  try {
362
- result = await transport.send(lanes.frame);
482
+ const send = () => transport.send(lanes.frame);
483
+ result = await (s.shared && !s.deactivated ? s.store.lock(send) : send());
363
484
  } catch {
364
485
  if (!s.running || isFinished(s)) return;
365
486
  s.frameFailures += 1;
@@ -396,15 +517,33 @@ const pump = async (s) => {
396
517
  }
397
518
  };
398
519
  const start = (s) => {
520
+ if (s.shared) {
521
+ s.shared.unsubscribe = s.store.subscribe(() => {
522
+ transact(s, () => consumeVerdicts(s));
523
+ });
524
+ s.running = true;
525
+ transact(s, () => {
526
+ if (s.store.load() === null) s.store.save(s.record);
527
+ s.recordPersisted = true;
528
+ adoptStored(s);
529
+ consumeVerdicts(s);
530
+ }).then(() => void pump(s));
531
+ return;
532
+ }
399
533
  if (!s.recordPersisted) {
400
534
  s.recordPersisted = true;
401
535
  s.store.save(s.record);
402
536
  }
537
+ adoptStored(s);
403
538
  s.running = true;
404
539
  pump(s);
405
540
  };
406
541
  const stop = (s) => {
407
542
  s.running = false;
543
+ if (s.shared) {
544
+ s.shared.unsubscribe?.();
545
+ s.shared.unsubscribe = null;
546
+ }
408
547
  wake(s);
409
548
  settleAll(s, "unmount");
410
549
  };
@@ -413,12 +552,8 @@ const setTransport = (s, transport) => {
413
552
  s.transport = transport;
414
553
  wake(s);
415
554
  };
416
- const enqueue = (s, command, params, resolve, reject) => {
555
+ const enqueueInner = (s, command, params, resolve, reject) => {
417
556
  const seq = s.record.nextSeq;
418
- setRecord(s, {
419
- ...s.record,
420
- nextSeq: seq + 1
421
- });
422
557
  const entry = {
423
558
  seq,
424
559
  command,
@@ -436,8 +571,47 @@ const enqueue = (s, command, params, resolve, reject) => {
436
571
  reject
437
572
  };
438
573
  s.pending.push(entry);
574
+ setRecord(s, {
575
+ ...s.record,
576
+ nextSeq: seq + 1,
577
+ commands: mergeBacklog(s)
578
+ });
439
579
  s.lifecycle?.onStart(entry.inv);
440
- pump(s);
580
+ };
581
+ const enqueue = (s, command, params, resolve, reject) => {
582
+ if (!s.shared) {
583
+ enqueueInner(s, command, params, resolve, reject);
584
+ pump(s);
585
+ return;
586
+ }
587
+ transact(s, () => enqueueInner(s, command, params, resolve, reject)).then(() => void pump(s));
588
+ };
589
+ const adoptStored = (s) => {
590
+ const known = new Set(s.pending.map((p) => p.seq));
591
+ const stored = s.record.commands.filter((c) => !known.has(c.seq));
592
+ if (stored.length === 0) return;
593
+ for (const { seq, ...command } of stored) {
594
+ const params = command.meta === void 0 ? command.params : reviveValue([...command.params], command.meta);
595
+ const entry = {
596
+ seq,
597
+ command,
598
+ inv: {
599
+ method: command.method,
600
+ params,
601
+ ts: Date.now(),
602
+ seq,
603
+ clientId: s.record.clientId
604
+ },
605
+ attempted: true,
606
+ posted: false,
607
+ applied: false,
608
+ resolve: () => {},
609
+ reject: () => {}
610
+ };
611
+ s.pending.push(entry);
612
+ s.lifecycle?.onStart(entry.inv);
613
+ }
614
+ s.pending.sort((a, b) => a.seq - b.seq);
441
615
  };
442
616
  const sendCommand = (s, method, params) => {
443
617
  if (!s.running) return rejectedSend({
@@ -461,36 +635,53 @@ const sendCommand = (s, method, params) => {
461
635
  };
462
636
  const setContext = (s, value) => {
463
637
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("statewire: context must be a plain object");
638
+ if (!s.running || isFinished(s)) return;
464
639
  const { value: encoded, meta } = encodeValue(value);
465
- s.ctxOps = [{
640
+ const ops = [{
466
641
  op: "replace",
467
642
  path: [],
468
643
  value: encoded,
469
644
  ...meta !== void 0 && { meta }
470
645
  }];
471
- const seq = s.record.ctxNextSeq;
472
- setRecord(s, {
473
- ...s.record,
474
- ctxNextSeq: seq + 1
475
- });
476
- s.ctxPending.push({
477
- seq,
478
- ops: s.ctxOps,
479
- posted: false
646
+ s.ctxOps = ops;
647
+ const mint = () => {
648
+ const seq = s.record.ctxNextSeq;
649
+ setRecord(s, {
650
+ ...s.record,
651
+ ctxNextSeq: seq + 1
652
+ });
653
+ s.ctxPending.push({
654
+ seq,
655
+ ops,
656
+ posted: false
657
+ });
658
+ };
659
+ if (!s.shared) {
660
+ mint();
661
+ wake(s);
662
+ pump(s);
663
+ return;
664
+ }
665
+ transact(s, mint).then(() => {
666
+ wake(s);
667
+ pump(s);
480
668
  });
481
- wake(s);
482
- pump(s);
483
669
  };
484
670
  const useStatewireSession = (options) => {
485
671
  const [defaultStore] = useState(createMemorySessionStore);
486
- const store = options.sessionStore ?? defaultStore;
672
+ const mounted = useContext(SessionStorageContext);
673
+ if (options.sessionStore !== void 0 && mounted !== void 0) throw new Error("statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session");
674
+ const store = options.sessionStore ?? mounted ?? defaultStore;
675
+ if (store.lock === void 0 !== (store.subscribe === void 0)) throw new Error("statewire: session storage must provide lock and subscribe together");
487
676
  const [s] = useState(() => {
488
677
  const stored = store.load();
678
+ if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
489
679
  const session = {
490
680
  record: stored ?? {
491
681
  clientId: newClientId(),
492
682
  nextSeq: 1,
493
- ctxNextSeq: 1
683
+ ctxNextSeq: 1,
684
+ commands: []
494
685
  },
495
686
  ctx: {
496
687
  get clientId() {
@@ -507,7 +698,13 @@ const useStatewireSession = (options) => {
507
698
  },
508
699
  recordPersisted: stored !== null,
509
700
  store,
701
+ shared: store.lock !== void 0 ? {
702
+ txq: Promise.resolve(),
703
+ parked: [],
704
+ unsubscribe: null
705
+ } : false,
510
706
  ephemeral: false,
707
+ deactivated: false,
511
708
  sessionKnown: false,
512
709
  durableSeq: 0,
513
710
  resumeAt: 0,