@phosphorco/bb-identity 0.3.0 → 0.4.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.
@@ -270,6 +270,9 @@ function sameVersion(a, b) {
270
270
  return a.status === b.status && version(a).epoch === version(b).epoch && version(a).sequence === version(b).sequence;
271
271
  }
272
272
  function createIdentityState(options) {
273
+ return createStateController(options);
274
+ }
275
+ function createStateController(options, editing = false) {
273
276
  const address = frozen({ ...options.address });
274
277
  const expected = frozen({ ...options.expected });
275
278
  const definition = options.definition;
@@ -303,6 +306,12 @@ function createIdentityState(options) {
303
306
  let closing = false;
304
307
  let checkpoint = null;
305
308
  let recoverySource = null;
309
+ let checkpointError = null;
310
+ let checkpointWriting = 0;
311
+ let lastRejection = null;
312
+ let rejectedGeneration = -1;
313
+ let ownerFenced = false;
314
+ let loadFailed = false;
306
315
  let automaticSuspensions = 0;
307
316
  let automaticEpoch = 0;
308
317
  let persistQueue = Promise.resolve();
@@ -318,7 +327,7 @@ function createIdentityState(options) {
318
327
  timer?.();
319
328
  timer = null;
320
329
  };
321
- const draft = () => base === null || desired === null ? null : frozen({ formatVersion: 1, key: { address, actor: expected.actor, ownerSession: options.ownerSession }, schemaVersion: definition.schemaVersion, acknowledged: base, desired: desired.value, localGeneration, inFlight: pending?.mutation ?? null });
330
+ const draft = () => base === null || desired === null ? null : frozen({ formatVersion: 1, key: { address, actor: expected.actor, ownerSession: options.ownerSession }, schemaVersion: definition.schemaVersion, acknowledged: base, desired: desired.value, localGeneration, inFlight: pending?.mutation ?? null, ...editing ? { rejection: lastRejection, rejectedGeneration } : {} });
322
331
  function emit() {
323
332
  if (lifecycle === "detached" || lifecycle === "disposed")
324
333
  snapshot = { status: "detached", address, draft: draft() };
@@ -342,6 +351,8 @@ function createIdentityState(options) {
342
351
  function sessionCheck() {
343
352
  if (!active())
344
353
  return err("disposed", "This state controller is no longer active.");
354
+ if (editing && ownerFenced)
355
+ return err("stale-context", "This editing owner was retired.");
345
356
  const result = options.currentSession();
346
357
  if (!result.ok) {
347
358
  block("identity-unavailable", result.error.message);
@@ -353,6 +364,8 @@ function createIdentityState(options) {
353
364
  return { ok: false, error: session.error };
354
365
  }
355
366
  if (session.instanceId !== address.instanceId || session.actor.identity.key !== expected.actor || session.stamp !== expected.session) {
367
+ if (editing)
368
+ ownerFenced = true;
356
369
  block("owner-changed", "The identity session changed.");
357
370
  return err("stale-context", "The identity session changed.", "after-refresh");
358
371
  }
@@ -373,11 +386,23 @@ function createIdentityState(options) {
373
386
  const capturedEpoch = epoch;
374
387
  const serial = ++persistSerial;
375
388
  const work = persistQueue.then(async () => {
389
+ checkpointWriting++;
390
+ if (editing)
391
+ emit();
376
392
  const result = await safe(() => drafts.write(captured, checkpoint?.revision ?? null));
377
- if (result.ok)
393
+ checkpointWriting--;
394
+ if (result.ok) {
378
395
  checkpoint = result.value;
379
- else if (active(capturedEpoch) && serial === persistSerial)
380
- block("storage-error", result.error.message);
396
+ if (editing)
397
+ checkpointError = null;
398
+ } else if (active(capturedEpoch) && serial === persistSerial) {
399
+ if (editing)
400
+ checkpointError = result.error;
401
+ else
402
+ block("storage-error", result.error.message);
403
+ }
404
+ if (editing)
405
+ emit();
381
406
  return result;
382
407
  });
383
408
  persistQueue = work.then(() => {
@@ -387,7 +412,7 @@ function createIdentityState(options) {
387
412
  }
388
413
  function schedule() {
389
414
  clearTimer();
390
- if (!active() || automaticSuspensions || closing || blocked || pending || !needsSave())
415
+ if (editing || !active() || automaticSuspensions || closing || blocked || pending || !needsSave())
391
416
  return;
392
417
  timer = scheduler.schedule(debounce, () => {
393
418
  timer = null;
@@ -456,7 +481,7 @@ function createIdentityState(options) {
456
481
  return ok(undefined);
457
482
  if (base && dirty()) {
458
483
  if (sameVersion(base, read)) {
459
- if (!blocked?.conflict)
484
+ if (!blocked?.conflict && (!editing || blocked?.reason !== "rejected"))
460
485
  blocked = null;
461
486
  emit();
462
487
  schedule();
@@ -466,7 +491,8 @@ function createIdentityState(options) {
466
491
  }
467
492
  base = read;
468
493
  desired = { value: valueClone(codec, currentValue(read)) };
469
- blocked = null;
494
+ if (!editing || blocked?.reason !== "rejected")
495
+ blocked = null;
470
496
  emit();
471
497
  schedule();
472
498
  return ok(undefined);
@@ -492,14 +518,17 @@ function createIdentityState(options) {
492
518
  if (!live.ok)
493
519
  return live;
494
520
  if (!loaded.ok) {
521
+ loadFailed = true;
495
522
  block("storage-error", loaded.error.message);
496
523
  return loaded;
497
524
  }
498
525
  const parsed = readClone(loaded.value);
499
526
  if (!parsed.ok) {
527
+ loadFailed = true;
500
528
  block("storage-error", parsed.error.message);
501
529
  return parsed;
502
530
  }
531
+ loadFailed = false;
503
532
  return applyRemote(parsed.value, cause);
504
533
  })();
505
534
  activeLoad = work;
@@ -513,6 +542,10 @@ function createIdentityState(options) {
513
542
  return work;
514
543
  }
515
544
  function outcomeCheck(outcome, operationId) {
545
+ if (editing && outcome.status === "rejected")
546
+ return outcome.operationId === operationId ? ok(frozen(outcome)) : err("incompatible", "Rejected receipt has another operation.");
547
+ if (outcome.status === "rejected")
548
+ return err("incompatible", "Rejection is not a legacy state outcome.");
516
549
  const parsed = codecs.save.decode(structuredClone(codecs.save.encode(outcome)));
517
550
  if (!parsed.ok)
518
551
  return parsed;
@@ -532,6 +565,17 @@ function createIdentityState(options) {
532
565
  pending = null;
533
566
  loadId++;
534
567
  blocked = null;
568
+ if (outcome.status === "rejected") {
569
+ lastRejection = outcome.evidence;
570
+ rejectedGeneration = captured.generation;
571
+ block("rejected", "The submitted edit was definitively rejected.");
572
+ const saved2 = await persist();
573
+ return saved2.ok ? err("invalid-input", "The submitted edit was definitively rejected.") : saved2;
574
+ }
575
+ if (editing && (outcome.status === "saved" || outcome.status === "unchanged")) {
576
+ lastRejection = null;
577
+ rejectedGeneration = -1;
578
+ }
535
579
  if (outcome.status === "saved" || outcome.status === "unchanged") {
536
580
  base = { status: "present", envelope: outcome.envelope };
537
581
  acknowledgedGeneration = Math.max(acknowledgedGeneration, captured.generation);
@@ -563,6 +607,8 @@ function createIdentityState(options) {
563
607
  return reload("reconnect");
564
608
  schedule();
565
609
  }
610
+ if (editing && snapshot.status === "blocked" && snapshot.reason === "conflict")
611
+ return err("conflict", snapshot.message);
566
612
  return ok(undefined);
567
613
  }
568
614
  function transmit(captured, automatic = false) {
@@ -631,7 +677,9 @@ function createIdentityState(options) {
631
677
  const valid = sessionCheck();
632
678
  if (!valid.ok)
633
679
  return Promise.resolve(valid);
634
- if (blocked)
680
+ if (editing && lastRejection && localGeneration <= rejectedGeneration)
681
+ return Promise.resolve(err("invalid-input", "Correct the rejected draft before submitting a new operation."));
682
+ if (blocked && !(editing && blocked.reason === "rejected" && localGeneration > rejectedGeneration))
635
683
  return Promise.resolve(err("conflict", blocked.message));
636
684
  if (activeSave)
637
685
  return activeSave;
@@ -640,6 +688,8 @@ function createIdentityState(options) {
640
688
  if (!base || !desired || !needsSave())
641
689
  return Promise.resolve(ok(undefined));
642
690
  const mutation = frozen({ kind: base.status === "empty" ? "initialize" : "replace", address, expectedVersion: { ...version(base) }, expected, ownerSession: options.ownerSession, localGeneration, operationId: newOperationId(), schemaVersion: definition.schemaVersion, value: valueClone(codec, desired.value) });
691
+ if (editing && blocked?.reason === "rejected")
692
+ blocked = null;
643
693
  pending = { mutation, base, generation: localGeneration };
644
694
  return transmit(pending, automatic);
645
695
  }
@@ -664,7 +714,7 @@ function createIdentityState(options) {
664
714
  const valid = sessionCheck();
665
715
  if (!valid.ok)
666
716
  return valid;
667
- const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId }, { signal: abort.signal }));
717
+ const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId, mutation: captured.mutation }, { signal: abort.signal }));
668
718
  if (!active(capturedEpoch) || pending !== captured)
669
719
  return err("cancelled", "State reconciliation was superseded.");
670
720
  const live = sessionCheck();
@@ -676,7 +726,7 @@ function createIdentityState(options) {
676
726
  }
677
727
  if (result.value.status === "final")
678
728
  return applyOutcome(captured, result.value.outcome);
679
- if (result.value.status === "absent-final" && allowReplay) {
729
+ if (result.value.status === "absent-final" && allowReplay && !editing) {
680
730
  if (captured.mutation.expected.actor !== expected.actor || captured.mutation.expected.session !== expected.session) {
681
731
  block("draft-recovery", "The original operation cannot be re-authored under a new session.");
682
732
  return err("stale-context", "The original operation requires its original session.");
@@ -692,6 +742,13 @@ function createIdentityState(options) {
692
742
  const valid = sessionCheck();
693
743
  if (!valid.ok)
694
744
  return valid;
745
+ if (editing) {
746
+ if (activeSave)
747
+ return activeSave;
748
+ if (pending)
749
+ return reconcile(false);
750
+ return sendNew();
751
+ }
695
752
  const goal = localGeneration;
696
753
  let retries = 0;
697
754
  if (activeSave) {
@@ -745,6 +802,27 @@ function createIdentityState(options) {
745
802
  }
746
803
  return {
747
804
  getSnapshot: () => snapshot,
805
+ inspect() {
806
+ const retained = draft();
807
+ const checkpointMatches = checkpoint !== null && retained !== null && checkpoint.draft.localGeneration === retained.localGeneration && JSON.stringify(checkpoint.draft.inFlight ? codecs.mutation.encode(checkpoint.draft.inFlight) : null) === JSON.stringify(retained.inFlight ? codecs.mutation.encode(retained.inFlight) : null) && JSON.stringify(checkpoint.draft.rejection ?? null) === JSON.stringify(retained.rejection ?? null) && JSON.stringify(codecs.read.encode(checkpoint.draft.acknowledged)) === JSON.stringify(codecs.read.encode(retained.acknowledged));
808
+ return {
809
+ draft: retained,
810
+ base,
811
+ desired: desired?.value,
812
+ generation: localGeneration,
813
+ pending: pending?.mutation ?? null,
814
+ saving: networkActive,
815
+ dirty: dirty(),
816
+ saveEligible: needsSave() && (!lastRejection || localGeneration > rejectedGeneration),
817
+ editable: active() && !closing && !ownerFenced && !loadFailed && base !== null && desired !== null && (!blocked || ["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason)),
818
+ checkpoint: {
819
+ status: !drafts ? "disabled" : checkpointWriting ? "writing" : checkpointError ? "failed" : checkpointMatches ? "backed-up" : "local-only",
820
+ generation: checkpoint?.draft.localGeneration ?? null,
821
+ revision: checkpoint?.revision ?? null,
822
+ error: checkpointError
823
+ }
824
+ };
825
+ },
748
826
  subscribe(listener) {
749
827
  if (lifecycle === "disposed")
750
828
  return () => {};
@@ -780,7 +858,7 @@ function createIdentityState(options) {
780
858
  const valid = sessionCheck();
781
859
  if (!valid.ok)
782
860
  return valid;
783
- if (closing || blocked || snapshot.status !== "ready")
861
+ if (closing || (editing ? ownerFenced || loadFailed || !base || !desired || !!blocked && !["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason) : blocked || snapshot.status !== "ready"))
784
862
  return err("unavailable", "State is not editable.");
785
863
  try {
786
864
  desired = { value: valueClone(codec, value) };
@@ -788,6 +866,8 @@ function createIdentityState(options) {
788
866
  return err("invalid-input", error instanceof Error ? error.message : "Invalid state.");
789
867
  }
790
868
  localGeneration++;
869
+ if (editing && blocked?.conflict)
870
+ blocked = { ...blocked, conflict: frozen({ ...blocked.conflict, token: newConflictToken(), local: desired.value }) };
791
871
  emit();
792
872
  schedule();
793
873
  return ok(undefined);
@@ -856,6 +936,10 @@ function createIdentityState(options) {
856
936
  return err("invalid-input", "Draft operation does not match its owner or generation.");
857
937
  mutation = frozen(result.value);
858
938
  }
939
+ if (editing) {
940
+ lastRejection = source.rejection ?? null;
941
+ rejectedGeneration = source.rejectedGeneration ?? -1;
942
+ }
859
943
  clearTimer();
860
944
  base = parsed.value;
861
945
  desired = { value: valueClone(codec, source.desired) };
@@ -946,5 +1030,6 @@ function createIdentityState(options) {
946
1030
  };
947
1031
  }
948
1032
  export {
1033
+ createStateController,
949
1034
  createIdentityState
950
1035
  };
@@ -406,6 +406,9 @@ function sameVersion(a, b) {
406
406
  return a.status === b.status && version(a).epoch === version(b).epoch && version(a).sequence === version(b).sequence;
407
407
  }
408
408
  function createIdentityState(options) {
409
+ return createStateController(options);
410
+ }
411
+ function createStateController(options, editing = false) {
409
412
  const address = frozen({ ...options.address });
410
413
  const expected = frozen({ ...options.expected });
411
414
  const definition = options.definition;
@@ -439,6 +442,12 @@ function createIdentityState(options) {
439
442
  let closing = false;
440
443
  let checkpoint = null;
441
444
  let recoverySource = null;
445
+ let checkpointError = null;
446
+ let checkpointWriting = 0;
447
+ let lastRejection = null;
448
+ let rejectedGeneration = -1;
449
+ let ownerFenced = false;
450
+ let loadFailed = false;
442
451
  let automaticSuspensions = 0;
443
452
  let automaticEpoch = 0;
444
453
  let persistQueue = Promise.resolve();
@@ -454,7 +463,7 @@ function createIdentityState(options) {
454
463
  timer?.();
455
464
  timer = null;
456
465
  };
457
- const draft = () => base === null || desired === null ? null : frozen({ formatVersion: 1, key: { address, actor: expected.actor, ownerSession: options.ownerSession }, schemaVersion: definition.schemaVersion, acknowledged: base, desired: desired.value, localGeneration, inFlight: pending?.mutation ?? null });
466
+ const draft = () => base === null || desired === null ? null : frozen({ formatVersion: 1, key: { address, actor: expected.actor, ownerSession: options.ownerSession }, schemaVersion: definition.schemaVersion, acknowledged: base, desired: desired.value, localGeneration, inFlight: pending?.mutation ?? null, ...editing ? { rejection: lastRejection, rejectedGeneration } : {} });
458
467
  function emit() {
459
468
  if (lifecycle === "detached" || lifecycle === "disposed")
460
469
  snapshot = { status: "detached", address, draft: draft() };
@@ -478,6 +487,8 @@ function createIdentityState(options) {
478
487
  function sessionCheck() {
479
488
  if (!active())
480
489
  return err("disposed", "This state controller is no longer active.");
490
+ if (editing && ownerFenced)
491
+ return err("stale-context", "This editing owner was retired.");
481
492
  const result = options.currentSession();
482
493
  if (!result.ok) {
483
494
  block("identity-unavailable", result.error.message);
@@ -489,6 +500,8 @@ function createIdentityState(options) {
489
500
  return { ok: false, error: session.error };
490
501
  }
491
502
  if (session.instanceId !== address.instanceId || session.actor.identity.key !== expected.actor || session.stamp !== expected.session) {
503
+ if (editing)
504
+ ownerFenced = true;
492
505
  block("owner-changed", "The identity session changed.");
493
506
  return err("stale-context", "The identity session changed.", "after-refresh");
494
507
  }
@@ -509,11 +522,23 @@ function createIdentityState(options) {
509
522
  const capturedEpoch = epoch;
510
523
  const serial = ++persistSerial;
511
524
  const work = persistQueue.then(async () => {
525
+ checkpointWriting++;
526
+ if (editing)
527
+ emit();
512
528
  const result = await safe(() => drafts.write(captured, checkpoint?.revision ?? null));
513
- if (result.ok)
529
+ checkpointWriting--;
530
+ if (result.ok) {
514
531
  checkpoint = result.value;
515
- else if (active(capturedEpoch) && serial === persistSerial)
516
- block("storage-error", result.error.message);
532
+ if (editing)
533
+ checkpointError = null;
534
+ } else if (active(capturedEpoch) && serial === persistSerial) {
535
+ if (editing)
536
+ checkpointError = result.error;
537
+ else
538
+ block("storage-error", result.error.message);
539
+ }
540
+ if (editing)
541
+ emit();
517
542
  return result;
518
543
  });
519
544
  persistQueue = work.then(() => {
@@ -523,7 +548,7 @@ function createIdentityState(options) {
523
548
  }
524
549
  function schedule() {
525
550
  clearTimer();
526
- if (!active() || automaticSuspensions || closing || blocked || pending || !needsSave())
551
+ if (editing || !active() || automaticSuspensions || closing || blocked || pending || !needsSave())
527
552
  return;
528
553
  timer = scheduler.schedule(debounce, () => {
529
554
  timer = null;
@@ -592,7 +617,7 @@ function createIdentityState(options) {
592
617
  return ok(undefined);
593
618
  if (base && dirty()) {
594
619
  if (sameVersion(base, read)) {
595
- if (!blocked?.conflict)
620
+ if (!blocked?.conflict && (!editing || blocked?.reason !== "rejected"))
596
621
  blocked = null;
597
622
  emit();
598
623
  schedule();
@@ -602,7 +627,8 @@ function createIdentityState(options) {
602
627
  }
603
628
  base = read;
604
629
  desired = { value: valueClone(codec, currentValue(read)) };
605
- blocked = null;
630
+ if (!editing || blocked?.reason !== "rejected")
631
+ blocked = null;
606
632
  emit();
607
633
  schedule();
608
634
  return ok(undefined);
@@ -628,14 +654,17 @@ function createIdentityState(options) {
628
654
  if (!live.ok)
629
655
  return live;
630
656
  if (!loaded.ok) {
657
+ loadFailed = true;
631
658
  block("storage-error", loaded.error.message);
632
659
  return loaded;
633
660
  }
634
661
  const parsed = readClone(loaded.value);
635
662
  if (!parsed.ok) {
663
+ loadFailed = true;
636
664
  block("storage-error", parsed.error.message);
637
665
  return parsed;
638
666
  }
667
+ loadFailed = false;
639
668
  return applyRemote(parsed.value, cause);
640
669
  })();
641
670
  activeLoad = work;
@@ -649,6 +678,10 @@ function createIdentityState(options) {
649
678
  return work;
650
679
  }
651
680
  function outcomeCheck(outcome, operationId) {
681
+ if (editing && outcome.status === "rejected")
682
+ return outcome.operationId === operationId ? ok(frozen(outcome)) : err("incompatible", "Rejected receipt has another operation.");
683
+ if (outcome.status === "rejected")
684
+ return err("incompatible", "Rejection is not a legacy state outcome.");
652
685
  const parsed = codecs.save.decode(structuredClone(codecs.save.encode(outcome)));
653
686
  if (!parsed.ok)
654
687
  return parsed;
@@ -668,6 +701,17 @@ function createIdentityState(options) {
668
701
  pending = null;
669
702
  loadId++;
670
703
  blocked = null;
704
+ if (outcome.status === "rejected") {
705
+ lastRejection = outcome.evidence;
706
+ rejectedGeneration = captured.generation;
707
+ block("rejected", "The submitted edit was definitively rejected.");
708
+ const saved2 = await persist();
709
+ return saved2.ok ? err("invalid-input", "The submitted edit was definitively rejected.") : saved2;
710
+ }
711
+ if (editing && (outcome.status === "saved" || outcome.status === "unchanged")) {
712
+ lastRejection = null;
713
+ rejectedGeneration = -1;
714
+ }
671
715
  if (outcome.status === "saved" || outcome.status === "unchanged") {
672
716
  base = { status: "present", envelope: outcome.envelope };
673
717
  acknowledgedGeneration = Math.max(acknowledgedGeneration, captured.generation);
@@ -699,6 +743,8 @@ function createIdentityState(options) {
699
743
  return reload("reconnect");
700
744
  schedule();
701
745
  }
746
+ if (editing && snapshot.status === "blocked" && snapshot.reason === "conflict")
747
+ return err("conflict", snapshot.message);
702
748
  return ok(undefined);
703
749
  }
704
750
  function transmit(captured, automatic = false) {
@@ -767,7 +813,9 @@ function createIdentityState(options) {
767
813
  const valid = sessionCheck();
768
814
  if (!valid.ok)
769
815
  return Promise.resolve(valid);
770
- if (blocked)
816
+ if (editing && lastRejection && localGeneration <= rejectedGeneration)
817
+ return Promise.resolve(err("invalid-input", "Correct the rejected draft before submitting a new operation."));
818
+ if (blocked && !(editing && blocked.reason === "rejected" && localGeneration > rejectedGeneration))
771
819
  return Promise.resolve(err("conflict", blocked.message));
772
820
  if (activeSave)
773
821
  return activeSave;
@@ -776,6 +824,8 @@ function createIdentityState(options) {
776
824
  if (!base || !desired || !needsSave())
777
825
  return Promise.resolve(ok(undefined));
778
826
  const mutation = frozen({ kind: base.status === "empty" ? "initialize" : "replace", address, expectedVersion: { ...version(base) }, expected, ownerSession: options.ownerSession, localGeneration, operationId: newOperationId(), schemaVersion: definition.schemaVersion, value: valueClone(codec, desired.value) });
827
+ if (editing && blocked?.reason === "rejected")
828
+ blocked = null;
779
829
  pending = { mutation, base, generation: localGeneration };
780
830
  return transmit(pending, automatic);
781
831
  }
@@ -800,7 +850,7 @@ function createIdentityState(options) {
800
850
  const valid = sessionCheck();
801
851
  if (!valid.ok)
802
852
  return valid;
803
- const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId }, { signal: abort.signal }));
853
+ const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId, mutation: captured.mutation }, { signal: abort.signal }));
804
854
  if (!active(capturedEpoch) || pending !== captured)
805
855
  return err("cancelled", "State reconciliation was superseded.");
806
856
  const live = sessionCheck();
@@ -812,7 +862,7 @@ function createIdentityState(options) {
812
862
  }
813
863
  if (result.value.status === "final")
814
864
  return applyOutcome(captured, result.value.outcome);
815
- if (result.value.status === "absent-final" && allowReplay) {
865
+ if (result.value.status === "absent-final" && allowReplay && !editing) {
816
866
  if (captured.mutation.expected.actor !== expected.actor || captured.mutation.expected.session !== expected.session) {
817
867
  block("draft-recovery", "The original operation cannot be re-authored under a new session.");
818
868
  return err("stale-context", "The original operation requires its original session.");
@@ -828,6 +878,13 @@ function createIdentityState(options) {
828
878
  const valid = sessionCheck();
829
879
  if (!valid.ok)
830
880
  return valid;
881
+ if (editing) {
882
+ if (activeSave)
883
+ return activeSave;
884
+ if (pending)
885
+ return reconcile(false);
886
+ return sendNew();
887
+ }
831
888
  const goal = localGeneration;
832
889
  let retries = 0;
833
890
  if (activeSave) {
@@ -881,6 +938,27 @@ function createIdentityState(options) {
881
938
  }
882
939
  return {
883
940
  getSnapshot: () => snapshot,
941
+ inspect() {
942
+ const retained = draft();
943
+ const checkpointMatches = checkpoint !== null && retained !== null && checkpoint.draft.localGeneration === retained.localGeneration && JSON.stringify(checkpoint.draft.inFlight ? codecs.mutation.encode(checkpoint.draft.inFlight) : null) === JSON.stringify(retained.inFlight ? codecs.mutation.encode(retained.inFlight) : null) && JSON.stringify(checkpoint.draft.rejection ?? null) === JSON.stringify(retained.rejection ?? null) && JSON.stringify(codecs.read.encode(checkpoint.draft.acknowledged)) === JSON.stringify(codecs.read.encode(retained.acknowledged));
944
+ return {
945
+ draft: retained,
946
+ base,
947
+ desired: desired?.value,
948
+ generation: localGeneration,
949
+ pending: pending?.mutation ?? null,
950
+ saving: networkActive,
951
+ dirty: dirty(),
952
+ saveEligible: needsSave() && (!lastRejection || localGeneration > rejectedGeneration),
953
+ editable: active() && !closing && !ownerFenced && !loadFailed && base !== null && desired !== null && (!blocked || ["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason)),
954
+ checkpoint: {
955
+ status: !drafts ? "disabled" : checkpointWriting ? "writing" : checkpointError ? "failed" : checkpointMatches ? "backed-up" : "local-only",
956
+ generation: checkpoint?.draft.localGeneration ?? null,
957
+ revision: checkpoint?.revision ?? null,
958
+ error: checkpointError
959
+ }
960
+ };
961
+ },
884
962
  subscribe(listener) {
885
963
  if (lifecycle === "disposed")
886
964
  return () => {};
@@ -916,7 +994,7 @@ function createIdentityState(options) {
916
994
  const valid = sessionCheck();
917
995
  if (!valid.ok)
918
996
  return valid;
919
- if (closing || blocked || snapshot.status !== "ready")
997
+ if (closing || (editing ? ownerFenced || loadFailed || !base || !desired || !!blocked && !["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason) : blocked || snapshot.status !== "ready"))
920
998
  return err("unavailable", "State is not editable.");
921
999
  try {
922
1000
  desired = { value: valueClone(codec, value) };
@@ -924,6 +1002,8 @@ function createIdentityState(options) {
924
1002
  return err("invalid-input", error instanceof Error ? error.message : "Invalid state.");
925
1003
  }
926
1004
  localGeneration++;
1005
+ if (editing && blocked?.conflict)
1006
+ blocked = { ...blocked, conflict: frozen({ ...blocked.conflict, token: newConflictToken(), local: desired.value }) };
927
1007
  emit();
928
1008
  schedule();
929
1009
  return ok(undefined);
@@ -992,6 +1072,10 @@ function createIdentityState(options) {
992
1072
  return err("invalid-input", "Draft operation does not match its owner or generation.");
993
1073
  mutation = frozen(result.value);
994
1074
  }
1075
+ if (editing) {
1076
+ lastRejection = source.rejection ?? null;
1077
+ rejectedGeneration = source.rejectedGeneration ?? -1;
1078
+ }
995
1079
  clearTimer();
996
1080
  base = parsed.value;
997
1081
  desired = { value: valueClone(codec, source.desired) };