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.
@@ -32,11 +32,17 @@ type StatewireSessionOptions = {
32
32
  sessionStore?: StatewireClient.SessionStorage | undefined;
33
33
  onDone?: (() => void) | undefined;
34
34
  };
35
+ type Shared = {
36
+ txq: Promise<void>;
37
+ parked: StatewireClient.SessionVerdict[];
38
+ unsubscribe: (() => void) | null;
39
+ };
35
40
  type Session = {
36
41
  record: StatewireClient.SessionRecord;
37
42
  ctx: StatewireRemoteProps;
38
43
  recordPersisted: boolean;
39
44
  store: StatewireClient.SessionStorage;
45
+ shared: Shared | false;
40
46
  ephemeral: boolean;
41
47
  /** An ephemeral record died with its attach: the storage is off for good. */
42
48
  deactivated: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","names":[],"sources":["../../src/client/session.ts"],"mappings":";;;;;;KA4BK;EACH;EACA;EACA;;KAGG;WACM;WACA,SAAS;WACT,KAAK;;EAEd;;EAEA;;EAEA;EACA,UAAU;EACV,SAAS,OAAO;;KAGb;WACM;WACA;EACT;;KAiBG;KAEO;EACV,eAAe,gBAAgB;EAC/B;;KAGG;EACH,QAAQ,gBAAgB;EACxB,KAAK;EACL;EACA,OAAO,gBAAgB;EACvB;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YAAY;EACZ;EACA,0BAA0B,qBAAqB;EAC/C;EACA,SAAS;EACT;EACA;EACA,SAAS;EACT,QAAQ,UAAU;EAClB,QAAQ;EACR;EACA;EACA;EACA,WAAW;EACX;EACA,WAAW;EACX;EACA,UAAU,mBAAmB,sBAAsB;;cA2e/C,eAAgB,GAAG,SAAS,WAAW;cA2FvC,aAAc,GAAG,SAAS,OAAO;cAoB1B,sBACX,SAAS,4BACR"}
1
+ {"version":3,"file":"session.d.ts","names":[],"sources":["../../src/client/session.ts"],"mappings":";;;;;;KA4BK;EACH;EACA;EACA;;KAGG;WACM;WACA,SAAS;WACT,KAAK;;EAEd;;EAEA;;EAEA;EACA,UAAU;EACV,SAAS,OAAO;;KAGb;WACM;WACA;EACT;;KAiBG;KAEO;EACV,eAAe,gBAAgB;EAC/B;;KAGG;EACH,KAAK;EACL,QAAQ,gBAAgB;EACxB;;KAGG;EACH,QAAQ,gBAAgB;EACxB,KAAK;EACL;EACA,OAAO,gBAAgB;EACvB,QAAQ;EACR;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YAAY;EACZ;EACA,0BAA0B,qBAAqB;EAC/C;EACA,SAAS;EACT;EACA;EACA,SAAS;EACT,QAAQ,UAAU;EAClB,QAAQ;EACR;EACA;EACA;EACA,WAAW;EACX;EACA,WAAW;EACX;EACA,UAAU,mBAAmB,sBAAsB;;cAgpB/C,eAAgB,GAAG,SAAS,WAAW;cA8GvC,aAAc,GAAG,SAAS,OAAO;cAgC1B,sBACX,SAAS,4BACR"}
@@ -47,14 +47,43 @@ const backlog = (s) => s.pending.filter((p) => p.seq > s.durableSeq).map((p) =>
47
47
  seq: p.seq,
48
48
  ...p.command
49
49
  }));
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;
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;
53
62
  setRecord(s, {
54
63
  ...s.record,
55
- commands
64
+ commands,
65
+ ...verdicts !== void 0 && { verdicts }
56
66
  });
57
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;
86
+ };
58
87
  const setIdle = (s, level) => {
59
88
  if (level === s.idle) return;
60
89
  s.idle = level;
@@ -151,11 +180,36 @@ const resetRecord = (s, clientId) => {
151
180
  });
152
181
  for (const old of survivors) {
153
182
  s.lifecycle?.onCovered?.(old.inv);
154
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
183
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
184
+ }
185
+ restateContext(s);
186
+ publish(s);
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);
155
208
  }
156
209
  restateContext(s);
157
210
  publish(s);
158
211
  wake(s);
212
+ pump(s);
159
213
  };
160
214
  const ephemeralDetach = (s) => {
161
215
  s.ephemeral = false;
@@ -171,6 +225,50 @@ const rejectedFailure = (cmd) => {
171
225
  ...cmd.payload !== void 0 && { payload: cmd.payload }
172
226
  };
173
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
+ };
174
272
  const handleCmd = (s, cmd) => {
175
273
  if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) protocolError("cmd.seq must be a positive safe integer");
176
274
  if (cmd.dur === true) {
@@ -183,35 +281,29 @@ const handleCmd = (s, cmd) => {
183
281
  if (entry) cover(s, entry);
184
282
  return;
185
283
  }
284
+ parkVerdict(s, cmd);
186
285
  if (!entry) return;
187
286
  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);
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);
215
307
  };
216
308
  const handleCmds = (s, cmds) => {
217
309
  for (const cmd of cmds) handleCmd(s, cmd);
@@ -242,7 +334,7 @@ const snapClocks = (s, seqWm, ctxWm) => {
242
334
  });
243
335
  for (const old of tail) {
244
336
  s.lifecycle?.onCovered?.(old.inv);
245
- enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
337
+ enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
246
338
  }
247
339
  }
248
340
  if (!(s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) && s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1)) {
@@ -265,17 +357,28 @@ const handleHello = (s, { ops, syn }) => {
265
357
  if (s.ephemeral) s.deactivated = true;
266
358
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
267
359
  s.attachLaneSeqs = [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()];
268
- if (record === "resumed") {
269
- for (const entry of s.pending) entry.posted = attachCmd.has(entry.seq);
270
- const ctxWatermark = syn.ctxSeq ?? 0;
271
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
272
- 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);
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
+ });
279
382
  };
280
383
  const handleOps = (s, { ops, cmds }) => {
281
384
  ingestOps(s, ops);
@@ -308,7 +411,7 @@ const handleDetach = (s) => {
308
411
  const handleFinish = (s, { reason }) => {
309
412
  if (reason === "evicted" || reason === "error") return;
310
413
  s.attach = "finished";
311
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
414
+ if (!(s.shared && reason === "superseded")) settleAll(s, reason === "superseded" ? "superseded" : "gone");
312
415
  publish(s);
313
416
  wake(s);
314
417
  };
@@ -376,7 +479,8 @@ const pump = async (s) => {
376
479
  for (const entry of lanes.cmdEntries) entry.attempted = true;
377
480
  let result;
378
481
  try {
379
- 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());
380
484
  } catch {
381
485
  if (!s.running || isFinished(s)) return;
382
486
  s.frameFailures += 1;
@@ -413,16 +517,33 @@ const pump = async (s) => {
413
517
  }
414
518
  };
415
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
+ }
416
533
  if (!s.recordPersisted) {
417
534
  s.recordPersisted = true;
418
535
  s.store.save(s.record);
419
536
  }
420
- resume(s);
537
+ adoptStored(s);
421
538
  s.running = true;
422
539
  pump(s);
423
540
  };
424
541
  const stop = (s) => {
425
542
  s.running = false;
543
+ if (s.shared) {
544
+ s.shared.unsubscribe?.();
545
+ s.shared.unsubscribe = null;
546
+ }
426
547
  wake(s);
427
548
  settleAll(s, "unmount");
428
549
  };
@@ -431,7 +552,7 @@ const setTransport = (s, transport) => {
431
552
  s.transport = transport;
432
553
  wake(s);
433
554
  };
434
- const enqueue = (s, command, params, resolve, reject) => {
555
+ const enqueueInner = (s, command, params, resolve, reject) => {
435
556
  const seq = s.record.nextSeq;
436
557
  const entry = {
437
558
  seq,
@@ -453,13 +574,22 @@ const enqueue = (s, command, params, resolve, reject) => {
453
574
  setRecord(s, {
454
575
  ...s.record,
455
576
  nextSeq: seq + 1,
456
- commands: backlog(s)
577
+ commands: mergeBacklog(s)
457
578
  });
458
579
  s.lifecycle?.onStart(entry.inv);
459
- pump(s);
460
580
  };
461
- const resume = (s) => {
462
- const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
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;
463
593
  for (const { seq, ...command } of stored) {
464
594
  const params = command.meta === void 0 ? command.params : reviveValue([...command.params], command.meta);
465
595
  const entry = {
@@ -481,6 +611,7 @@ const resume = (s) => {
481
611
  s.pending.push(entry);
482
612
  s.lifecycle?.onStart(entry.inv);
483
613
  }
614
+ s.pending.sort((a, b) => a.seq - b.seq);
484
615
  };
485
616
  const sendCommand = (s, method, params) => {
486
617
  if (!s.running) return rejectedSend({
@@ -504,31 +635,44 @@ const sendCommand = (s, method, params) => {
504
635
  };
505
636
  const setContext = (s, value) => {
506
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;
507
639
  const { value: encoded, meta } = encodeValue(value);
508
- s.ctxOps = [{
640
+ const ops = [{
509
641
  op: "replace",
510
642
  path: [],
511
643
  value: encoded,
512
644
  ...meta !== void 0 && { meta }
513
645
  }];
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
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);
523
668
  });
524
- wake(s);
525
- pump(s);
526
669
  };
527
670
  const useStatewireSession = (options) => {
528
671
  const [defaultStore] = useState(createMemorySessionStore);
529
672
  const mounted = useContext(SessionStorageContext);
530
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");
531
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");
532
676
  const [s] = useState(() => {
533
677
  const stored = store.load();
534
678
  if (stored !== null && !Array.isArray(stored.commands)) throw new Error("statewire: session storage loaded a record without a commands array");
@@ -554,6 +698,11 @@ const useStatewireSession = (options) => {
554
698
  },
555
699
  recordPersisted: stored !== null,
556
700
  store,
701
+ shared: store.lock !== void 0 ? {
702
+ txq: Promise.resolve(),
703
+ parked: [],
704
+ unsubscribe: null
705
+ } : false,
557
706
  ephemeral: false,
558
707
  deactivated: false,
559
708
  sessionKnown: false,