@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.
- package/EDITING.md +131 -0
- package/NATIVE-SOURCES.md +222 -0
- package/README.md +18 -0
- package/dist/bb-entry-runtime.js +107 -87
- package/dist/client-entry-runtime.js +128 -36
- package/dist/editing-runtime.js +1845 -0
- package/dist/host-runtime.js +54 -29
- package/dist/index-runtime.js +21 -0
- package/dist/model-runtime.js +21 -0
- package/dist/native-runtime.js +476 -0
- package/dist/native-testing-runtime.js +11 -0
- package/dist/react-entry-runtime.js +253 -51
- package/dist/state-controller-runtime.js +96 -11
- package/dist/state-runtime.js +95 -11
- package/dist/testing-runtime.js +112 -92
- package/editing.d.ts +137 -0
- package/host.d.ts +1 -0
- package/native-testing.d.ts +15 -0
- package/native.d.ts +100 -0
- package/package.json +20 -3
- package/react.d.ts +16 -0
|
@@ -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
|
-
|
|
393
|
+
checkpointWriting--;
|
|
394
|
+
if (result.ok) {
|
|
378
395
|
checkpoint = result.value;
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
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 (
|
|
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
|
};
|
package/dist/state-runtime.js
CHANGED
|
@@ -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
|
-
|
|
529
|
+
checkpointWriting--;
|
|
530
|
+
if (result.ok) {
|
|
514
531
|
checkpoint = result.value;
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
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 (
|
|
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) };
|