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.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +6 -4
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +10 -2
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -1
  7. package/dist/client/session.d.ts +10 -2
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +246 -97
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +1 -1
  12. package/dist/host/http.js +4 -3
  13. package/dist/host/http.js.map +1 -1
  14. package/dist/host/socket.d.ts +6 -5
  15. package/dist/host/socket.d.ts.map +1 -1
  16. package/dist/host/socket.js +12 -20
  17. package/dist/host/socket.js.map +1 -1
  18. package/dist/host-internal.d.ts +2 -1
  19. package/dist/host-internal.d.ts.map +1 -1
  20. package/dist/host.d.ts +2 -2
  21. package/dist/host.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/statewire.d.ts +29 -3
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js.map +1 -1
  26. package/dist/transports/fake-sse.js +4 -4
  27. package/dist/transports/fake-sse.js.map +1 -1
  28. package/dist/transports/fixtures.d.ts +1 -1
  29. package/dist/transports/remote.d.ts +1 -1
  30. package/dist/transports/remote.js +1 -0
  31. package/dist/transports/remote.js.map +1 -1
  32. package/dist/transports/sse.d.ts +2 -2
  33. package/dist/transports/testing.d.ts +1 -1
  34. package/dist/transports/testing.js +2 -4
  35. package/dist/transports/testing.js.map +1 -1
  36. package/dist/wire/meta.d.ts +5 -3
  37. package/dist/wire/meta.d.ts.map +1 -1
  38. package/dist/wire/meta.js +7 -4
  39. package/dist/wire/meta.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client/connection.ts +12 -6
  42. package/src/client/session.ts +332 -114
  43. package/src/host/http.ts +10 -9
  44. package/src/host/socket.ts +18 -28
  45. package/src/host-internal.ts +2 -1
  46. package/src/host.ts +0 -1
  47. package/src/statewire.ts +33 -3
  48. package/src/transports/fake-sse.ts +6 -6
  49. package/src/transports/remote.ts +1 -0
  50. package/src/transports/testing.ts +2 -7
  51. package/src/wire/meta.ts +12 -4
@@ -5,6 +5,7 @@ import { bindCommandsHost } from "../host/host.js";
5
5
  import { encodeParams, encodeValue, reviveResponse, reviveValue } from "../wire/meta.js";
6
6
  import { SessionStorageContext, createMemorySessionStore } from "./sessionStore.js";
7
7
  import { backoff } from "./timing.js";
8
+ import { TERMINAL } from "./connection.js";
8
9
  import { randomId } from "../util/randomId.js";
9
10
  import { useContext, useEffect, useState } from "@assistant-ui/tap/react-shim";
10
11
  //#region src/client/session.ts
@@ -47,14 +48,43 @@ const backlog = (s) => s.pending.filter((p) => p.seq > s.durableSeq).map((p) =>
47
48
  seq: p.seq,
48
49
  ...p.command
49
50
  }));
50
- const syncBacklog = (s) => {
51
- const commands = backlog(s);
52
- if (commands.length === s.record.commands.length && commands.every((c, i) => c.seq === s.record.commands[i].seq)) return;
51
+ const mergeBacklog = (s) => {
52
+ const own = backlog(s);
53
+ if (!s.shared) return own;
54
+ const known = new Set(s.pending.map((p) => p.seq));
55
+ return [...s.record.commands.filter((c) => !known.has(c.seq) && c.seq > s.durableSeq), ...own].sort((a, b) => a.seq - b.seq);
56
+ };
57
+ const syncBacklogInner = (s) => {
58
+ const parked = s.shared ? s.shared.parked.splice(0) : [];
59
+ const parkedSeqs = new Set(parked.map((v) => v.seq));
60
+ const commands = mergeBacklog(s).filter((c) => !parkedSeqs.has(c.seq));
61
+ const verdicts = parked.length > 0 ? [...s.record.verdicts ?? [], ...parked].slice(-64) : s.record.verdicts;
62
+ if (verdicts === s.record.verdicts && commands.length === s.record.commands.length && commands.every((c, i) => c.seq === s.record.commands[i].seq)) return;
53
63
  setRecord(s, {
54
64
  ...s.record,
55
- commands
65
+ commands,
66
+ ...verdicts !== void 0 && { verdicts }
56
67
  });
57
68
  };
69
+ const syncBacklog = (s) => {
70
+ if (!s.shared) return syncBacklogInner(s);
71
+ transact(s, () => syncBacklogInner(s));
72
+ };
73
+ const transact = (s, fn) => {
74
+ if (!s.shared || s.deactivated) {
75
+ fn();
76
+ return Promise.resolve();
77
+ }
78
+ const shared = s.shared;
79
+ const run = shared.txq.then(() => s.store.lock(async () => {
80
+ const stored = s.store.load();
81
+ if (stored !== null && stored.clientId !== s.record.clientId) adoptReset(s, stored);
82
+ else if (stored !== null) s.record = stored;
83
+ fn();
84
+ }));
85
+ shared.txq = run.then(() => {}, () => {});
86
+ return run;
87
+ };
58
88
  const setIdle = (s, level) => {
59
89
  if (level === s.idle) return;
60
90
  s.idle = level;
@@ -84,11 +114,7 @@ const settleEntry = (s, entry, outcome) => {
84
114
  }
85
115
  };
86
116
  const settleAll = (s, detach) => {
87
- const cause = {
88
- unmount: "unmount",
89
- gone: "stream-gone",
90
- superseded: "superseded"
91
- }[detach];
117
+ const cause = detach === "unmount" ? "unmount" : "stream-gone";
92
118
  const batch = s.pending;
93
119
  const detachFailure = (entry) => {
94
120
  if (!entry.attempted) {
@@ -129,9 +155,8 @@ const restateContext = (s) => {
129
155
  posted: false
130
156
  });
131
157
  };
132
- const resetRecord = (s, clientId) => {
133
- const survivors = [];
134
- for (const entry of [...s.pending]) if (entry.attempted || entry.applied) {
158
+ const settleLost = (s, entries) => {
159
+ for (const entry of entries) {
135
160
  s.lifecycle?.onCovered?.(entry.inv);
136
161
  settleEntry(s, entry, {
137
162
  ok: false,
@@ -140,23 +165,33 @@ const resetRecord = (s, clientId) => {
140
165
  cause: "session-lost"
141
166
  }
142
167
  });
143
- } else survivors.push(entry);
168
+ }
169
+ };
170
+ const rebaseRecord = (s, adopt) => {
171
+ const held = [...s.pending];
172
+ const survivors = held.filter((p) => !p.attempted && !p.applied);
173
+ settleLost(s, held.filter((p) => p.attempted || p.applied));
144
174
  s.pending = [];
145
175
  s.durableSeq = 0;
146
- setRecord(s, {
147
- clientId,
148
- nextSeq: 1,
149
- ctxNextSeq: 1,
150
- commands: []
151
- });
176
+ adopt();
152
177
  for (const old of survivors) {
153
178
  s.lifecycle?.onCovered?.(old.inv);
154
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
179
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
155
180
  }
156
181
  restateContext(s);
157
182
  publish(s);
158
183
  wake(s);
184
+ pump(s);
159
185
  };
186
+ const resetRecord = (s, clientId) => rebaseRecord(s, () => setRecord(s, {
187
+ clientId,
188
+ nextSeq: 1,
189
+ ctxNextSeq: 1,
190
+ commands: []
191
+ }));
192
+ const adoptReset = (s, stored) => rebaseRecord(s, () => {
193
+ s.record = stored;
194
+ });
160
195
  const ephemeralDetach = (s) => {
161
196
  s.ephemeral = false;
162
197
  s.sessionKnown = false;
@@ -171,6 +206,53 @@ const rejectedFailure = (cmd) => {
171
206
  ...cmd.payload !== void 0 && { payload: cmd.payload }
172
207
  };
173
208
  };
209
+ const outcomeOf = (cmd) => {
210
+ switch (cmd.type) {
211
+ case "result": return {
212
+ ok: true,
213
+ payload: reviveResponse(cmd).payload
214
+ };
215
+ case "rejected": return {
216
+ ok: false,
217
+ failure: rejectedFailure(reviveResponse(cmd))
218
+ };
219
+ case "failed": return {
220
+ ok: false,
221
+ failure: {
222
+ fate: "failed",
223
+ cause: "server",
224
+ ...cmd.message !== void 0 && { message: cmd.message }
225
+ }
226
+ };
227
+ case "crashed": return {
228
+ ok: false,
229
+ failure: {
230
+ fate: "crashed",
231
+ cause: "server"
232
+ }
233
+ };
234
+ case "expired": return {
235
+ ok: false,
236
+ failure: {
237
+ fate: "expired",
238
+ cause: "server"
239
+ }
240
+ };
241
+ default: return protocolError(`unknown cmd type: ${String(cmd.type)}`);
242
+ }
243
+ };
244
+ const TERMINAL_CMD_TYPES = new Set(TERMINAL);
245
+ const parkVerdict = (s, cmd) => {
246
+ if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type)) return;
247
+ s.shared.parked.push({
248
+ seq: cmd.seq,
249
+ type: cmd.type,
250
+ ...cmd.code !== void 0 && { code: cmd.code },
251
+ ...cmd.message !== void 0 && { message: cmd.message },
252
+ ...cmd.payload !== void 0 && { payload: cmd.payload },
253
+ ...cmd.meta !== void 0 && { meta: cmd.meta }
254
+ });
255
+ };
174
256
  const handleCmd = (s, cmd) => {
175
257
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) protocolError("cmd.seq must be a positive safe integer");
176
258
  if (cmd.dur === true) {
@@ -183,35 +265,29 @@ const handleCmd = (s, cmd) => {
183
265
  if (entry) cover(s, entry);
184
266
  return;
185
267
  }
268
+ parkVerdict(s, cmd);
186
269
  if (!entry) return;
187
270
  cover(s, entry);
188
- const outcome = cmd.type === "result" ? {
189
- ok: true,
190
- payload: reviveResponse(cmd).payload
191
- } : cmd.type === "rejected" ? {
192
- ok: false,
193
- failure: rejectedFailure(reviveResponse(cmd))
194
- } : cmd.type === "failed" ? {
195
- ok: false,
196
- failure: {
197
- fate: "failed",
198
- cause: "server",
199
- ...cmd.message !== void 0 && { message: cmd.message }
200
- }
201
- } : cmd.type === "crashed" ? {
202
- ok: false,
203
- failure: {
204
- fate: "crashed",
205
- cause: "server"
206
- }
207
- } : cmd.type === "expired" ? {
208
- ok: false,
209
- failure: {
210
- fate: "expired",
211
- cause: "server"
212
- }
213
- } : protocolError(`unknown cmd type: ${String(cmd.type)}`);
214
- settleEntry(s, entry, outcome);
271
+ settleEntry(s, entry, outcomeOf(cmd));
272
+ };
273
+ const consumeVerdicts = (s) => {
274
+ const verdicts = s.record.verdicts ?? [];
275
+ const consumed = /* @__PURE__ */ new Set();
276
+ for (const verdict of verdicts) {
277
+ const entry = s.pending.find((p) => p.seq === verdict.seq);
278
+ if (!entry) continue;
279
+ consumed.add(verdict.seq);
280
+ cover(s, entry);
281
+ settleEntry(s, entry, outcomeOf(verdict));
282
+ }
283
+ if (consumed.size === 0) return;
284
+ setRecord(s, {
285
+ ...s.record,
286
+ commands: s.record.commands.filter((c) => !consumed.has(c.seq)),
287
+ verdicts: verdicts.filter((v) => !consumed.has(v.seq))
288
+ });
289
+ publish(s);
290
+ wake(s);
215
291
  };
216
292
  const handleCmds = (s, cmds) => {
217
293
  for (const cmd of cmds) handleCmd(s, cmd);
@@ -227,24 +303,7 @@ const publishIngested = (s) => {
227
303
  publish(s);
228
304
  wake(s);
229
305
  };
230
- const RECORDS = [
231
- "fresh",
232
- "resumed",
233
- "ephemeral"
234
- ];
235
- const snapClocks = (s, seqWm, ctxWm) => {
236
- const tail = s.pending.filter((p) => p.seq > seqWm);
237
- if (!(tail.every((p, i) => p.seq === seqWm + 1 + i) && s.record.nextSeq === seqWm + tail.length + 1)) {
238
- s.pending = s.pending.filter((p) => p.seq <= seqWm);
239
- setRecord(s, {
240
- ...s.record,
241
- nextSeq: seqWm + 1
242
- });
243
- for (const old of tail) {
244
- s.lifecycle?.onCovered?.(old.inv);
245
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
246
- }
247
- }
306
+ const snapCtxClock = (s, ctxWm) => {
248
307
  if (!(s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) && s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1)) {
249
308
  setRecord(s, {
250
309
  ...s.record,
@@ -253,29 +312,54 @@ const snapClocks = (s, seqWm, ctxWm) => {
253
312
  restateContext(s);
254
313
  }
255
314
  };
315
+ const sessionLostInner = (s, seqWm, ctxWm) => {
316
+ const held = [...s.pending];
317
+ settleLost(s, held);
318
+ s.pending = [];
319
+ s.durableSeq = seqWm;
320
+ setRecord(s, {
321
+ clientId: s.record.clientId,
322
+ nextSeq: seqWm + 1,
323
+ ctxNextSeq: ctxWm + 1,
324
+ commands: []
325
+ });
326
+ restateContext(s);
327
+ if (held.length > 0 && s.onSessionLost?.(held.map((p) => p.inv)) === "requeue") for (const old of held) enqueueInner(s, old.command, old.inv.params, () => {}, () => {});
328
+ publish(s);
329
+ wake(s);
330
+ };
256
331
  const handleHello = (s, { ops, syn }) => {
257
- const record = syn.record ?? "fresh";
258
- if (!RECORDS.includes(record)) protocolError(`unknown record: ${String(record)}`);
332
+ if (syn.record !== void 0 && syn.record !== "ephemeral") protocolError(`unknown record: ${String(syn.record)}`);
259
333
  ingestOps(s, ops);
260
334
  s.attach = "attached";
261
335
  s.attachEpoch += 1;
262
336
  s.frameFailures = 0;
263
337
  s.resumeAt = 0;
264
- s.ephemeral = record === "ephemeral";
338
+ s.ephemeral = syn.record === "ephemeral";
265
339
  if (s.ephemeral) s.deactivated = true;
266
340
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
267
341
  s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
268
- if (record === "resumed") {
342
+ const reconcile = () => {
343
+ if (s.shared) adoptStored(s);
344
+ s.durableSeq = syn.seq ?? 0;
269
345
  for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
270
346
  const ctxWatermark = syn.ctxSeq ?? 0;
271
347
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
272
348
  for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
273
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
274
- syncBacklog(s);
275
- } else resetRecord(s, s.record.clientId);
276
- s.sessionKnown = true;
277
- publishIngested(s);
278
- pump(s);
349
+ snapCtxClock(s, ctxWatermark);
350
+ syncBacklogInner(s);
351
+ s.sessionKnown = true;
352
+ };
353
+ if (!s.shared || s.deactivated) {
354
+ reconcile();
355
+ publishIngested(s);
356
+ pump(s);
357
+ return;
358
+ }
359
+ transact(s, reconcile).then(() => {
360
+ publishIngested(s);
361
+ pump(s);
362
+ });
279
363
  };
280
364
  const handleOps = (s, { ops, cmds }) => {
281
365
  ingestOps(s, ops);
@@ -293,10 +377,26 @@ const handleRefusal = (s, syn) => {
293
377
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
294
378
  for (const c of s.ctxPending) c.posted = false;
295
379
  }
296
- if (typeof syn.retry === "number") s.resumeAt = Date.now() + syn.retry;
380
+ if (typeof syn.retry === "number") {
381
+ s.resumeAt = Date.now() + syn.retry;
382
+ wake(s);
383
+ pump(s);
384
+ return;
385
+ }
297
386
  const firstCmd = s.pending.find((p) => !p.posted);
298
387
  const firstCtx = s.ctxPending.find((c) => !c.posted);
299
- 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();
388
+ const cmdGap = typeof seq === "number" && firstCmd !== void 0 && firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
389
+ const ctxGap = typeof ctxSeq === "number" && firstCtx !== void 0 && firstCtx.seq > ctxSeq + 1;
390
+ if (cmdGap || ctxGap) {
391
+ transact(s, () => {
392
+ if (cmdGap) sessionLostInner(s, seq, ctxSeq ?? 0);
393
+ else snapCtxClock(s, ctxSeq);
394
+ }).then(() => {
395
+ wake(s);
396
+ pump(s);
397
+ });
398
+ return;
399
+ }
300
400
  wake(s);
301
401
  pump(s);
302
402
  };
@@ -308,7 +408,7 @@ const handleDetach = (s) => {
308
408
  const handleFinish = (s, { reason }) => {
309
409
  if (reason === "evicted" || reason === "error") return;
310
410
  s.attach = "finished";
311
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
411
+ settleAll(s, "gone");
312
412
  publish(s);
313
413
  wake(s);
314
414
  };
@@ -376,7 +476,8 @@ const pump = async (s) => {
376
476
  for (const entry of lanes.cmdEntries) entry.attempted = true;
377
477
  let result;
378
478
  try {
379
- result = await transport.send(lanes.frame);
479
+ const send = () => transport.send(lanes.frame);
480
+ result = await (s.shared && !s.deactivated ? s.store.lock(send) : send());
380
481
  } catch {
381
482
  if (!s.running || isFinished(s)) return;
382
483
  s.frameFailures += 1;
@@ -413,16 +514,33 @@ const pump = async (s) => {
413
514
  }
414
515
  };
415
516
  const start = (s) => {
517
+ if (s.shared) {
518
+ s.shared.unsubscribe = s.store.subscribe(() => {
519
+ transact(s, () => consumeVerdicts(s));
520
+ });
521
+ s.running = true;
522
+ transact(s, () => {
523
+ if (s.store.load() === null) s.store.save(s.record);
524
+ s.recordPersisted = true;
525
+ adoptStored(s);
526
+ consumeVerdicts(s);
527
+ }).then(() => void pump(s));
528
+ return;
529
+ }
416
530
  if (!s.recordPersisted) {
417
531
  s.recordPersisted = true;
418
532
  s.store.save(s.record);
419
533
  }
420
- resume(s);
534
+ adoptStored(s);
421
535
  s.running = true;
422
536
  pump(s);
423
537
  };
424
538
  const stop = (s) => {
425
539
  s.running = false;
540
+ if (s.shared) {
541
+ s.shared.unsubscribe?.();
542
+ s.shared.unsubscribe = null;
543
+ }
426
544
  wake(s);
427
545
  settleAll(s, "unmount");
428
546
  };
@@ -431,7 +549,7 @@ const setTransport = (s, transport) => {
431
549
  s.transport = transport;
432
550
  wake(s);
433
551
  };
434
- const enqueue = (s, command, params, resolve, reject) => {
552
+ const enqueueInner = (s, command, params, resolve, reject) => {
435
553
  const seq = s.record.nextSeq;
436
554
  const entry = {
437
555
  seq,
@@ -453,13 +571,22 @@ const enqueue = (s, command, params, resolve, reject) => {
453
571
  setRecord(s, {
454
572
  ...s.record,
455
573
  nextSeq: seq + 1,
456
- commands: backlog(s)
574
+ commands: mergeBacklog(s)
457
575
  });
458
576
  s.lifecycle?.onStart(entry.inv);
459
- pump(s);
460
577
  };
461
- const resume = (s) => {
462
- const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
578
+ const enqueue = (s, command, params, resolve, reject) => {
579
+ if (!s.shared) {
580
+ enqueueInner(s, command, params, resolve, reject);
581
+ pump(s);
582
+ return;
583
+ }
584
+ transact(s, () => enqueueInner(s, command, params, resolve, reject)).then(() => void pump(s));
585
+ };
586
+ const adoptStored = (s) => {
587
+ const known = new Set(s.pending.map((p) => p.seq));
588
+ const stored = s.record.commands.filter((c) => !known.has(c.seq));
589
+ if (stored.length === 0) return;
463
590
  for (const { seq, ...command } of stored) {
464
591
  const params = command.meta === void 0 ? command.params : reviveValue([...command.params], command.meta);
465
592
  const entry = {
@@ -481,6 +608,7 @@ const resume = (s) => {
481
608
  s.pending.push(entry);
482
609
  s.lifecycle?.onStart(entry.inv);
483
610
  }
611
+ s.pending.sort((a, b) => a.seq - b.seq);
484
612
  };
485
613
  const sendCommand = (s, method, params) => {
486
614
  if (!s.running) return rejectedSend({
@@ -504,31 +632,44 @@ const sendCommand = (s, method, params) => {
504
632
  };
505
633
  const setContext = (s, value) => {
506
634
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("statewire: context must be a plain object");
635
+ if (!s.running || isFinished(s)) return;
507
636
  const { value: encoded, meta } = encodeValue(value);
508
- s.ctxOps = [{
637
+ const ops = [{
509
638
  op: "replace",
510
639
  path: [],
511
640
  value: encoded,
512
641
  ...meta !== void 0 && { meta }
513
642
  }];
514
- const seq = s.record.ctxNextSeq;
515
- setRecord(s, {
516
- ...s.record,
517
- ctxNextSeq: seq + 1
518
- });
519
- s.ctxPending.push({
520
- seq,
521
- ops: s.ctxOps,
522
- posted: false
643
+ s.ctxOps = ops;
644
+ const mint = () => {
645
+ const seq = s.record.ctxNextSeq;
646
+ setRecord(s, {
647
+ ...s.record,
648
+ ctxNextSeq: seq + 1
649
+ });
650
+ s.ctxPending.push({
651
+ seq,
652
+ ops,
653
+ posted: false
654
+ });
655
+ };
656
+ if (!s.shared) {
657
+ mint();
658
+ wake(s);
659
+ pump(s);
660
+ return;
661
+ }
662
+ transact(s, mint).then(() => {
663
+ wake(s);
664
+ pump(s);
523
665
  });
524
- wake(s);
525
- pump(s);
526
666
  };
527
667
  const useStatewireSession = (options) => {
528
668
  const [defaultStore] = useState(createMemorySessionStore);
529
669
  const mounted = useContext(SessionStorageContext);
530
670
  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");
531
671
  const store = options.sessionStore ?? mounted ?? defaultStore;
672
+ if (store.lock === void 0 !== (store.subscribe === void 0)) throw new Error("statewire: session storage must provide lock and subscribe together");
532
673
  const [s] = useState(() => {
533
674
  const stored = store.load();
534
675
  if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
@@ -554,12 +695,18 @@ const useStatewireSession = (options) => {
554
695
  },
555
696
  recordPersisted: stored !== null,
556
697
  store,
698
+ shared: store.lock !== void 0 ? {
699
+ txq: Promise.resolve(),
700
+ parked: [],
701
+ unsubscribe: null
702
+ } : false,
557
703
  ephemeral: false,
558
704
  deactivated: false,
559
705
  sessionKnown: false,
560
706
  durableSeq: 0,
561
707
  resumeAt: 0,
562
708
  idle: false,
709
+ onSessionLost: void 0,
563
710
  onDone: void 0,
564
711
  ctxOps: void 0,
565
712
  ctxPending: [],
@@ -596,7 +743,9 @@ const useStatewireSession = (options) => {
596
743
  } });
597
744
  return session;
598
745
  });
746
+ if (store.lock !== void 0 !== (s.shared !== false)) throw new Error("statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never");
599
747
  s.store = store;
748
+ s.onSessionLost = options.onSessionLost;
600
749
  s.onDone = options.onDone;
601
750
  useEffect(() => {
602
751
  start(s);