statewire 0.18.0 → 0.18.2

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.
@@ -117,7 +117,7 @@ type Session = {
117
117
  idle: boolean;
118
118
  onSessionLost: StatewireClient.OnSessionLost | undefined;
119
119
  onDone: (() => void) | undefined;
120
- ctxOps: unknown[] | undefined;
120
+ ctxNextSeq: number;
121
121
  ctxPending: CtxPending[];
122
122
  lanesOnAttach: boolean;
123
123
  attachLaneSeqs: readonly [ReadonlySet<number>, ReadonlySet<number>];
@@ -244,20 +244,6 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
244
244
  adoptReset(s, stored);
245
245
  } else if (stored !== null) {
246
246
  s.record = stored;
247
- if (stored.context !== undefined) {
248
- const posted = new Set(
249
- s.ctxPending.filter((p) => p.posted).map((p) => p.seq),
250
- );
251
- s.ctxOps = stored.context.ops;
252
- s.contextDocuments = applyDocumentOps(
253
- { mode: "mounted", documents: new Map() },
254
- stored.context.ops,
255
- );
256
- s.ctxPending = stored.context.pending.map((p) => ({
257
- ...p,
258
- posted: posted.has(p.seq),
259
- }));
260
- }
261
247
  }
262
248
  fn();
263
249
  const persistence = s.store.flush?.();
@@ -388,14 +374,6 @@ const settleAll = (s: Session, detach: "unmount" | "gone") => {
388
374
  }
389
375
  };
390
376
 
391
- const restateContext = (s: Session) => {
392
- s.ctxPending = [];
393
- if (s.ctxOps === undefined) return;
394
- const seq = s.record.ctxNextSeq;
395
- setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
396
- s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
397
- };
398
-
399
377
  const settleLost = (s: Session, entries: Pending[]) => {
400
378
  for (const entry of entries) {
401
379
  s.lifecycle?.onCovered?.(entry.inv);
@@ -432,16 +410,13 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
432
410
  s.lifecycle?.onCovered?.(old.inv);
433
411
  enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
434
412
  }
435
- restateContext(s);
436
413
  publish(s);
437
414
  wake(s);
438
415
  void pump(s);
439
416
  };
440
417
 
441
418
  const resetRecord = (s: Session, clientId: string) =>
442
- rebaseRecord(s, () =>
443
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
444
- );
419
+ rebaseRecord(s, () => setRecord(s, { clientId, nextSeq: 1, commands: [] }));
445
420
 
446
421
  // Another sharer replaced the record's identity: their reset settles our
447
422
  // attempted history and our survivors re-stamp on the stored clocks.
@@ -605,54 +580,11 @@ const publishIngested = (s: Session) => {
605
580
  wake(s);
606
581
  };
607
582
 
608
- // A hello's cmd watermark may understate a durable record (cold host, tray
609
- // unloaded): held seqs resend as-is and the frame's answer arbitrates —
610
- // absorption where the record survived, a gap refusal where it did not.
611
- const snapCtxClock = (s: Session, ctxWm: number) => {
612
- if (s.record.context?.recover === "version-1") {
613
- const document = s.contextDocuments.documents.get(0);
614
- if (s.contextDocuments.documents.size !== 1 || !document?.main)
615
- throw new Error(
616
- "statewire: version-1 context recovery requires one main document at doc 0",
617
- );
618
- const encoded = encodeDocument(document.value);
619
- const ops = [
620
- {
621
- ...(ctxWm === 0
622
- ? { op: "mount", protocol: document.protocol, main: true }
623
- : { op: "replace", path: [] }),
624
- ...encoded,
625
- },
626
- ];
627
- s.ctxPending = [{ seq: ctxWm + 1, ops, posted: false }];
628
- setRecord(s, {
629
- ...s.record,
630
- ctxNextSeq: ctxWm + 2,
631
- context: {
632
- ops: contextSnapshot(s.contextDocuments),
633
- pending: s.ctxPending,
634
- },
635
- });
636
- return;
637
- }
638
- const ctxAligned =
639
- s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
640
- s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
641
- if (!ctxAligned) {
642
- if (ctxWm > 0)
643
- throw new Error(
644
- "statewire: context history is missing; use a new client id",
645
- );
646
- setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
647
- restateContext(s);
648
- }
649
- };
650
-
651
- // The refusal's watermarks are exact: every held statement above them was
652
- // never admitted by the live record, and the first unposted seq being beyond
583
+ // The refusal's watermark is exact: every held statement above it was never
584
+ // admitted by the live record, and the first unposted seq being beyond
653
585
  // watermark+1 proves the record that admitted the missing prefix is gone —
654
- // its history settles lost and the lane clocks restart at the watermarks.
655
- const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
586
+ // its history settles lost and the cmd clock restarts at the watermark.
587
+ const sessionLostInner = (s: Session, seqWm: number) => {
656
588
  const held = [...s.pending];
657
589
  settleLost(s, held);
658
590
  s.pending = [];
@@ -660,11 +592,9 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
660
592
  setRecord(s, {
661
593
  clientId: s.record.clientId,
662
594
  nextSeq: seqWm + 1,
663
- ctxNextSeq: s.record.ctxNextSeq,
664
595
  commands: [],
665
596
  ...(s.record.failed !== undefined && { failed: s.record.failed }),
666
597
  });
667
- snapCtxClock(s, ctxWm);
668
598
  if (
669
599
  held.length > 0 &&
670
600
  s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
@@ -690,6 +620,9 @@ const handleHello = (
690
620
  if (syn.record !== undefined && syn.record !== "ephemeral") {
691
621
  protocolError(`unknown record: ${String(syn.record)}`);
692
622
  }
623
+ if (syn.ctxSeq !== undefined && syn.ctxSeq !== 0) {
624
+ protocolError(`hello ctxSeq must be 0, got ${syn.ctxSeq}`);
625
+ }
693
626
  s.selection =
694
627
  syn.version === undefined || syn.protocols === undefined
695
628
  ? undefined
@@ -712,11 +645,7 @@ const handleHello = (
712
645
  for (const entry of s.pending) {
713
646
  entry.posted = attachCmd.has(entry.seq);
714
647
  }
715
- const ctxWatermark = syn.ctxSeq ?? 0;
716
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
717
648
  for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
718
- snapCtxClock(s, ctxWatermark);
719
- if (s.ctxOps !== undefined) persistContext(s);
720
649
  syncBacklogInner(s);
721
650
  s.sessionKnown = true;
722
651
  };
@@ -754,7 +683,12 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
754
683
  if (typeof ctxSeq === "number") {
755
684
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
756
685
  for (const c of s.ctxPending) c.posted = false;
757
- if (s.ctxOps !== undefined) persistContext(s);
686
+ const firstCtx = s.ctxPending.find((c) => !c.posted);
687
+ if (firstCtx !== undefined && firstCtx.seq > ctxSeq + 1) {
688
+ protocolError(
689
+ `ctx gap on a live attach: seq ${firstCtx.seq} after watermark ${ctxSeq}`,
690
+ );
691
+ }
758
692
  }
759
693
  if (typeof syn.retry === "number") {
760
694
  s.resumeAt = Date.now() + syn.retry;
@@ -763,22 +697,14 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
763
697
  return;
764
698
  }
765
699
  const firstCmd = s.pending.find((p) => !p.posted);
766
- const firstCtx = s.ctxPending.find((c) => !c.posted);
767
700
  const cmdGap =
768
701
  typeof seq === "number" &&
769
702
  firstCmd !== undefined &&
770
703
  firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
771
- const ctxGap =
772
- typeof ctxSeq === "number" &&
773
- firstCtx !== undefined &&
774
- firstCtx.seq > ctxSeq + 1;
775
- if (cmdGap || ctxGap) {
704
+ if (cmdGap) {
776
705
  transactBackground(
777
706
  s,
778
- () => {
779
- if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
780
- else snapCtxClock(s, ctxSeq!);
781
- },
707
+ () => sessionLostInner(s, seq),
782
708
  () => {
783
709
  wake(s);
784
710
  void pump(s);
@@ -814,16 +740,13 @@ type Lanes = {
814
740
 
815
741
  // A lane must be strictly consecutive within a frame: the first unposted run
816
742
  // per lane rides now, later runs ride follow-up frames.
817
- const buildLanes = (s: Session, persisted = false): Lanes | null => {
743
+ const buildLanes = (
744
+ s: Session,
745
+ cmdGate: "persisted" | "all" | "none" = "all",
746
+ ): Lanes | null => {
818
747
  const ctxEntries: CtxPending[] = [];
819
748
  for (const entry of s.ctxPending) {
820
749
  if (entry.posted) continue;
821
- if (
822
- persisted &&
823
- (s.persistedRecord?.clientId !== s.record.clientId ||
824
- entry.seq >= s.persistedRecord.ctxNextSeq)
825
- )
826
- break;
827
750
  if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
828
751
  if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
829
752
  break;
@@ -831,9 +754,10 @@ const buildLanes = (s: Session, persisted = false): Lanes | null => {
831
754
  }
832
755
  const cmdEntries: Pending[] = [];
833
756
  for (const entry of s.pending) {
757
+ if (cmdGate === "none") break;
834
758
  if (entry.posted) continue;
835
759
  if (
836
- persisted &&
760
+ cmdGate === "persisted" &&
837
761
  (s.persistedRecord?.clientId !== s.record.clientId ||
838
762
  entry.seq >= s.persistedRecord.nextSeq)
839
763
  )
@@ -861,13 +785,20 @@ const buildLanes = (s: Session, persisted = false): Lanes | null => {
861
785
  };
862
786
  };
863
787
 
788
+ // Context is attach scoped: every attach starts at ctx seq 0 with an empty
789
+ // table, so the full context restates at seq 1 on the attach frame.
790
+ const resetContextLane = (s: Session) => {
791
+ const ops = contextSnapshot(s.contextDocuments);
792
+ s.ctxPending = ops.length === 0 ? [] : [{ seq: 1, ops, posted: false }];
793
+ s.ctxNextSeq = s.ctxPending.length + 1;
794
+ };
795
+
864
796
  const takeAttachLanes = (s: Session): StatewireFrame | null => {
865
797
  s.attachLaneSeqs = [new Set(), new Set()];
798
+ resetContextLane(s);
866
799
  const wanted = s.lanesOnAttach;
867
800
  s.lanesOnAttach = false;
868
- if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
869
- return null;
870
- const lanes = buildLanes(s, true);
801
+ const lanes = buildLanes(s, wanted && s.sessionKnown ? "persisted" : "none");
871
802
  if (!lanes) return null;
872
803
  s.attachLaneSeqs = [
873
804
  new Set(lanes.cmdEntries.map((p) => p.seq)),
@@ -1042,7 +973,7 @@ const enqueueInner = (
1042
973
  resolve: (payload: unknown) => void,
1043
974
  reject: (error: unknown) => void,
1044
975
  ) => {
1045
- const seq = s.record.nextSeq;
976
+ const seq = Math.max(s.record.nextSeq, s.durableSeq + 1);
1046
977
  const entry: Pending = {
1047
978
  seq,
1048
979
  command,
@@ -1299,83 +1230,38 @@ const contextSnapshot = (documents: StatewireDocuments.State) =>
1299
1230
  };
1300
1231
  });
1301
1232
 
1302
- const persistContext = (s: Session) => {
1303
- const ops = contextSnapshot(s.contextDocuments);
1304
- s.ctxOps = ops;
1305
- setRecord(s, {
1306
- ...s.record,
1307
- context: {
1308
- ops,
1309
- ...(s.record.context?.recover === undefined
1310
- ? {}
1311
- : { recover: s.record.context.recover }),
1312
- pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
1313
- },
1314
- });
1315
- };
1316
-
1317
1233
  const changeContext = (
1318
1234
  s: Session,
1319
1235
  build: (documents: StatewireDocuments.State) => readonly unknown[],
1320
1236
  ): void => {
1321
- if (!s.hydrated) {
1322
- void s
1323
- .ready!.then(() => changeContext(s, build))
1324
- .catch((cause: unknown) => {
1325
- const error = wrapStorageError(cause);
1326
- s.storageError = error;
1327
- s.transport?.storageFailed(error);
1328
- });
1329
- return;
1330
- }
1331
1237
  if (!s.running || isFinished(s)) return;
1332
- const submit = () => {
1333
- if (!s.running || isFinished(s)) return;
1334
- const previous =
1335
- s.record.context === undefined
1336
- ? s.contextDocuments
1337
- : applyDocumentOps(
1338
- { mode: "mounted", documents: new Map() },
1339
- s.record.context.ops,
1340
- );
1341
- const operations = build(previous);
1342
- if (!operations.length)
1343
- throw new Error("statewire: context operations must not be empty");
1344
- const offers = protocolOffers(s.options);
1345
- const protocols =
1346
- s.selection?.protocols ?? (offers === "*" ? undefined : offers);
1347
- s.contextDocuments = applyDocumentOps(
1348
- previous,
1349
- operations,
1350
- protocols,
1351
- true,
1352
- );
1353
- const seq = s.record.ctxNextSeq;
1354
- s.ctxPending = [
1355
- ...s.ctxPending,
1356
- {
1357
- seq,
1358
- ops: operations.map((op) => {
1359
- const value = op as Record<string, unknown>;
1360
- if (value["doc"] !== 0) return value;
1361
- const { doc: _doc, ...rest } = value;
1362
- return rest;
1363
- }),
1364
- posted: false,
1365
- },
1366
- ];
1367
- s.record = { ...s.record, ctxNextSeq: seq + 1 };
1368
- persistContext(s);
1369
- };
1370
- if (!s.shared) {
1371
- submit();
1372
- wake(s);
1373
- void pump(s);
1374
- } else
1375
- transactBackground(s, submit, () => {
1376
- wake(s);
1377
- void pump(s);
1378
- });
1238
+ const operations = build(s.contextDocuments);
1239
+ if (!operations.length)
1240
+ throw new Error("statewire: context operations must not be empty");
1241
+ const offers = protocolOffers(s.options);
1242
+ const protocols =
1243
+ s.selection?.protocols ?? (offers === "*" ? undefined : offers);
1244
+ s.contextDocuments = applyDocumentOps(
1245
+ s.contextDocuments,
1246
+ operations,
1247
+ protocols,
1248
+ true,
1249
+ );
1250
+ s.ctxPending = [
1251
+ ...s.ctxPending,
1252
+ {
1253
+ seq: s.ctxNextSeq++,
1254
+ ops: operations.map((op) => {
1255
+ const value = op as Record<string, unknown>;
1256
+ if (value["doc"] !== 0) return value;
1257
+ const { doc: _doc, ...rest } = value;
1258
+ return rest;
1259
+ }),
1260
+ posted: false,
1261
+ },
1262
+ ];
1263
+ wake(s);
1264
+ void pump(s);
1379
1265
  };
1380
1266
 
1381
1267
  export const updateContext = (s: Session, operations: readonly unknown[]) =>
@@ -1506,11 +1392,10 @@ const loadSessionRecord = (
1506
1392
  const record = stored ?? {
1507
1393
  clientId: newClientId(),
1508
1394
  nextSeq: 1,
1509
- ctxNextSeq: 1,
1510
1395
  commands: [],
1511
1396
  };
1512
1397
  if (stored === null) store.save(record);
1513
- return { record, stored };
1398
+ return record;
1514
1399
  };
1515
1400
 
1516
1401
  export const useStatewireSession = (
@@ -1531,28 +1416,14 @@ export const useStatewireSession = (
1531
1416
  );
1532
1417
  }
1533
1418
  const [s] = useState((): Session => {
1534
- const { record, stored } =
1419
+ const record =
1535
1420
  store.ready === undefined
1536
1421
  ? loadSessionRecord(store, options)
1537
- : {
1538
- record: {
1539
- clientId: newClientId(),
1540
- nextSeq: 1,
1541
- ctxNextSeq: 1,
1542
- commands: [],
1543
- },
1544
- stored: null,
1545
- };
1422
+ : { clientId: newClientId(), nextSeq: 1, commands: [] };
1546
1423
  const session: Session = {
1547
1424
  options,
1548
1425
  selection: undefined,
1549
- contextDocuments:
1550
- stored?.context === undefined
1551
- ? { mode: "mounted", documents: new Map() }
1552
- : applyDocumentOps(
1553
- { mode: "mounted", documents: new Map() },
1554
- stored.context.ops,
1555
- ),
1426
+ contextDocuments: { mode: "mounted", documents: new Map() },
1556
1427
  record,
1557
1428
  ctx: {
1558
1429
  beforeAttach: () => persistBeforeSend(session),
@@ -1587,9 +1458,8 @@ export const useStatewireSession = (
1587
1458
  idle: false,
1588
1459
  onSessionLost: undefined,
1589
1460
  onDone: undefined,
1590
- ctxOps: stored?.context?.ops,
1591
- ctxPending:
1592
- stored?.context?.pending.map((p) => ({ ...p, posted: false })) ?? [],
1461
+ ctxNextSeq: 1,
1462
+ ctxPending: [],
1593
1463
  lanesOnAttach: false,
1594
1464
  attachLaneSeqs: [new Set(), new Set()],
1595
1465
  frameFailures: 0,
@@ -1624,21 +1494,7 @@ export const useStatewireSession = (
1624
1494
  };
1625
1495
  if (store.ready !== undefined) {
1626
1496
  session.ready = store.ready.then(() => {
1627
- const { record } = loadSessionRecord(store, session.options);
1628
- session.record = record;
1629
- session.contextDocuments =
1630
- record.context === undefined
1631
- ? { mode: "mounted", documents: new Map() }
1632
- : applyDocumentOps(
1633
- { mode: "mounted", documents: new Map() },
1634
- record.context.ops,
1635
- );
1636
- session.ctxOps = record.context?.ops;
1637
- session.ctxPending =
1638
- record.context?.pending.map((entry) => ({
1639
- ...entry,
1640
- posted: false,
1641
- })) ?? [];
1497
+ session.record = loadSessionRecord(store, session.options);
1642
1498
  session.recordPersisted = true;
1643
1499
  session.hydrated = true;
1644
1500
  if (session.running) session.notify();
package/src/host/http.ts CHANGED
@@ -226,13 +226,7 @@ const admitAttachFrame = (
226
226
  frame: ParsedFrame,
227
227
  ) => {
228
228
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
229
- const admission = admitFrame(
230
- h,
231
- registration.rec,
232
- frame,
233
- registration.conn.auth,
234
- registration.conn.selection,
235
- );
229
+ const admission = admitFrame(h, registration.conn, frame);
236
230
  if (admission.type === "refused") {
237
231
  sendTo(h, registration.conn, JSON.stringify(admission.syn));
238
232
  }
@@ -376,7 +370,6 @@ export const frames = async (
376
370
  return Response.json({ error: "stale-lease" }, { status: 423 });
377
371
  }
378
372
  const clientId = leased.clientId;
379
- const rec = h.clients.get(clientId)!;
380
373
  const raw = await readFrameBody(request);
381
374
  if (raw === null) {
382
375
  return Response.json({ error: "frame body too large" }, { status: 413 });
@@ -395,7 +388,7 @@ export const frames = async (
395
388
  }
396
389
  let admission: ReturnType<typeof admitFrame>;
397
390
  try {
398
- admission = admitFrame(h, rec, frame, leased.auth, leased.selection);
391
+ admission = admitFrame(h, leased, frame);
399
392
  } catch (error) {
400
393
  if (!(error instanceof Error) || error.name !== "StatewireViolation")
401
394
  throw error;