@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.
@@ -1584,6 +1584,9 @@ function sameVersion(a, b) {
1584
1584
  return a.status === b.status && version(a).epoch === version(b).epoch && version(a).sequence === version(b).sequence;
1585
1585
  }
1586
1586
  function createIdentityState(options) {
1587
+ return createStateController(options);
1588
+ }
1589
+ function createStateController(options, editing = false) {
1587
1590
  const address = frozen({ ...options.address });
1588
1591
  const expected = frozen({ ...options.expected });
1589
1592
  const definition = options.definition;
@@ -1617,6 +1620,12 @@ function createIdentityState(options) {
1617
1620
  let closing = false;
1618
1621
  let checkpoint = null;
1619
1622
  let recoverySource = null;
1623
+ let checkpointError = null;
1624
+ let checkpointWriting = 0;
1625
+ let lastRejection = null;
1626
+ let rejectedGeneration = -1;
1627
+ let ownerFenced = false;
1628
+ let loadFailed = false;
1620
1629
  let automaticSuspensions = 0;
1621
1630
  let automaticEpoch = 0;
1622
1631
  let persistQueue = Promise.resolve();
@@ -1632,7 +1641,7 @@ function createIdentityState(options) {
1632
1641
  timer?.();
1633
1642
  timer = null;
1634
1643
  };
1635
- 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 });
1644
+ 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 } : {} });
1636
1645
  function emit() {
1637
1646
  if (lifecycle === "detached" || lifecycle === "disposed")
1638
1647
  snapshot = { status: "detached", address, draft: draft() };
@@ -1656,6 +1665,8 @@ function createIdentityState(options) {
1656
1665
  function sessionCheck() {
1657
1666
  if (!active())
1658
1667
  return err("disposed", "This state controller is no longer active.");
1668
+ if (editing && ownerFenced)
1669
+ return err("stale-context", "This editing owner was retired.");
1659
1670
  const result = options.currentSession();
1660
1671
  if (!result.ok) {
1661
1672
  block("identity-unavailable", result.error.message);
@@ -1667,6 +1678,8 @@ function createIdentityState(options) {
1667
1678
  return { ok: false, error: session.error };
1668
1679
  }
1669
1680
  if (session.instanceId !== address.instanceId || session.actor.identity.key !== expected.actor || session.stamp !== expected.session) {
1681
+ if (editing)
1682
+ ownerFenced = true;
1670
1683
  block("owner-changed", "The identity session changed.");
1671
1684
  return err("stale-context", "The identity session changed.", "after-refresh");
1672
1685
  }
@@ -1687,11 +1700,23 @@ function createIdentityState(options) {
1687
1700
  const capturedEpoch = epoch;
1688
1701
  const serial = ++persistSerial;
1689
1702
  const work = persistQueue.then(async () => {
1703
+ checkpointWriting++;
1704
+ if (editing)
1705
+ emit();
1690
1706
  const result = await safe(() => drafts.write(captured, checkpoint?.revision ?? null));
1691
- if (result.ok)
1707
+ checkpointWriting--;
1708
+ if (result.ok) {
1692
1709
  checkpoint = result.value;
1693
- else if (active(capturedEpoch) && serial === persistSerial)
1694
- block("storage-error", result.error.message);
1710
+ if (editing)
1711
+ checkpointError = null;
1712
+ } else if (active(capturedEpoch) && serial === persistSerial) {
1713
+ if (editing)
1714
+ checkpointError = result.error;
1715
+ else
1716
+ block("storage-error", result.error.message);
1717
+ }
1718
+ if (editing)
1719
+ emit();
1695
1720
  return result;
1696
1721
  });
1697
1722
  persistQueue = work.then(() => {
@@ -1701,7 +1726,7 @@ function createIdentityState(options) {
1701
1726
  }
1702
1727
  function schedule() {
1703
1728
  clearTimer();
1704
- if (!active() || automaticSuspensions || closing || blocked || pending || !needsSave())
1729
+ if (editing || !active() || automaticSuspensions || closing || blocked || pending || !needsSave())
1705
1730
  return;
1706
1731
  timer = scheduler.schedule(debounce, () => {
1707
1732
  timer = null;
@@ -1770,7 +1795,7 @@ function createIdentityState(options) {
1770
1795
  return ok(undefined);
1771
1796
  if (base && dirty()) {
1772
1797
  if (sameVersion(base, read)) {
1773
- if (!blocked?.conflict)
1798
+ if (!blocked?.conflict && (!editing || blocked?.reason !== "rejected"))
1774
1799
  blocked = null;
1775
1800
  emit();
1776
1801
  schedule();
@@ -1780,7 +1805,8 @@ function createIdentityState(options) {
1780
1805
  }
1781
1806
  base = read;
1782
1807
  desired = { value: valueClone(codec, currentValue(read)) };
1783
- blocked = null;
1808
+ if (!editing || blocked?.reason !== "rejected")
1809
+ blocked = null;
1784
1810
  emit();
1785
1811
  schedule();
1786
1812
  return ok(undefined);
@@ -1806,14 +1832,17 @@ function createIdentityState(options) {
1806
1832
  if (!live.ok)
1807
1833
  return live;
1808
1834
  if (!loaded.ok) {
1835
+ loadFailed = true;
1809
1836
  block("storage-error", loaded.error.message);
1810
1837
  return loaded;
1811
1838
  }
1812
1839
  const parsed = readClone(loaded.value);
1813
1840
  if (!parsed.ok) {
1841
+ loadFailed = true;
1814
1842
  block("storage-error", parsed.error.message);
1815
1843
  return parsed;
1816
1844
  }
1845
+ loadFailed = false;
1817
1846
  return applyRemote(parsed.value, cause);
1818
1847
  })();
1819
1848
  activeLoad = work;
@@ -1827,6 +1856,10 @@ function createIdentityState(options) {
1827
1856
  return work;
1828
1857
  }
1829
1858
  function outcomeCheck(outcome, operationId) {
1859
+ if (editing && outcome.status === "rejected")
1860
+ return outcome.operationId === operationId ? ok(frozen(outcome)) : err("incompatible", "Rejected receipt has another operation.");
1861
+ if (outcome.status === "rejected")
1862
+ return err("incompatible", "Rejection is not a legacy state outcome.");
1830
1863
  const parsed = codecs.save.decode(structuredClone(codecs.save.encode(outcome)));
1831
1864
  if (!parsed.ok)
1832
1865
  return parsed;
@@ -1846,6 +1879,17 @@ function createIdentityState(options) {
1846
1879
  pending = null;
1847
1880
  loadId++;
1848
1881
  blocked = null;
1882
+ if (outcome.status === "rejected") {
1883
+ lastRejection = outcome.evidence;
1884
+ rejectedGeneration = captured.generation;
1885
+ block("rejected", "The submitted edit was definitively rejected.");
1886
+ const saved2 = await persist();
1887
+ return saved2.ok ? err("invalid-input", "The submitted edit was definitively rejected.") : saved2;
1888
+ }
1889
+ if (editing && (outcome.status === "saved" || outcome.status === "unchanged")) {
1890
+ lastRejection = null;
1891
+ rejectedGeneration = -1;
1892
+ }
1849
1893
  if (outcome.status === "saved" || outcome.status === "unchanged") {
1850
1894
  base = { status: "present", envelope: outcome.envelope };
1851
1895
  acknowledgedGeneration = Math.max(acknowledgedGeneration, captured.generation);
@@ -1877,6 +1921,8 @@ function createIdentityState(options) {
1877
1921
  return reload("reconnect");
1878
1922
  schedule();
1879
1923
  }
1924
+ if (editing && snapshot.status === "blocked" && snapshot.reason === "conflict")
1925
+ return err("conflict", snapshot.message);
1880
1926
  return ok(undefined);
1881
1927
  }
1882
1928
  function transmit(captured, automatic = false) {
@@ -1945,7 +1991,9 @@ function createIdentityState(options) {
1945
1991
  const valid = sessionCheck();
1946
1992
  if (!valid.ok)
1947
1993
  return Promise.resolve(valid);
1948
- if (blocked)
1994
+ if (editing && lastRejection && localGeneration <= rejectedGeneration)
1995
+ return Promise.resolve(err("invalid-input", "Correct the rejected draft before submitting a new operation."));
1996
+ if (blocked && !(editing && blocked.reason === "rejected" && localGeneration > rejectedGeneration))
1949
1997
  return Promise.resolve(err("conflict", blocked.message));
1950
1998
  if (activeSave)
1951
1999
  return activeSave;
@@ -1954,6 +2002,8 @@ function createIdentityState(options) {
1954
2002
  if (!base || !desired || !needsSave())
1955
2003
  return Promise.resolve(ok(undefined));
1956
2004
  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) });
2005
+ if (editing && blocked?.reason === "rejected")
2006
+ blocked = null;
1957
2007
  pending = { mutation, base, generation: localGeneration };
1958
2008
  return transmit(pending, automatic);
1959
2009
  }
@@ -1978,7 +2028,7 @@ function createIdentityState(options) {
1978
2028
  const valid = sessionCheck();
1979
2029
  if (!valid.ok)
1980
2030
  return valid;
1981
- const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId }, { signal: abort.signal }));
2031
+ const result = await safe(() => options.transport.reconcile({ address, expected, operationId: captured.mutation.operationId, mutation: captured.mutation }, { signal: abort.signal }));
1982
2032
  if (!active(capturedEpoch) || pending !== captured)
1983
2033
  return err("cancelled", "State reconciliation was superseded.");
1984
2034
  const live = sessionCheck();
@@ -1990,7 +2040,7 @@ function createIdentityState(options) {
1990
2040
  }
1991
2041
  if (result.value.status === "final")
1992
2042
  return applyOutcome(captured, result.value.outcome);
1993
- if (result.value.status === "absent-final" && allowReplay) {
2043
+ if (result.value.status === "absent-final" && allowReplay && !editing) {
1994
2044
  if (captured.mutation.expected.actor !== expected.actor || captured.mutation.expected.session !== expected.session) {
1995
2045
  block("draft-recovery", "The original operation cannot be re-authored under a new session.");
1996
2046
  return err("stale-context", "The original operation requires its original session.");
@@ -2006,6 +2056,13 @@ function createIdentityState(options) {
2006
2056
  const valid = sessionCheck();
2007
2057
  if (!valid.ok)
2008
2058
  return valid;
2059
+ if (editing) {
2060
+ if (activeSave)
2061
+ return activeSave;
2062
+ if (pending)
2063
+ return reconcile(false);
2064
+ return sendNew();
2065
+ }
2009
2066
  const goal = localGeneration;
2010
2067
  let retries = 0;
2011
2068
  if (activeSave) {
@@ -2059,6 +2116,27 @@ function createIdentityState(options) {
2059
2116
  }
2060
2117
  return {
2061
2118
  getSnapshot: () => snapshot,
2119
+ inspect() {
2120
+ const retained = draft();
2121
+ 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));
2122
+ return {
2123
+ draft: retained,
2124
+ base,
2125
+ desired: desired?.value,
2126
+ generation: localGeneration,
2127
+ pending: pending?.mutation ?? null,
2128
+ saving: networkActive,
2129
+ dirty: dirty(),
2130
+ saveEligible: needsSave() && (!lastRejection || localGeneration > rejectedGeneration),
2131
+ editable: active() && !closing && !ownerFenced && !loadFailed && base !== null && desired !== null && (!blocked || ["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason)),
2132
+ checkpoint: {
2133
+ status: !drafts ? "disabled" : checkpointWriting ? "writing" : checkpointError ? "failed" : checkpointMatches ? "backed-up" : "local-only",
2134
+ generation: checkpoint?.draft.localGeneration ?? null,
2135
+ revision: checkpoint?.revision ?? null,
2136
+ error: checkpointError
2137
+ }
2138
+ };
2139
+ },
2062
2140
  subscribe(listener) {
2063
2141
  if (lifecycle === "disposed")
2064
2142
  return () => {};
@@ -2094,7 +2172,7 @@ function createIdentityState(options) {
2094
2172
  const valid = sessionCheck();
2095
2173
  if (!valid.ok)
2096
2174
  return valid;
2097
- if (closing || blocked || snapshot.status !== "ready")
2175
+ if (closing || (editing ? ownerFenced || loadFailed || !base || !desired || !!blocked && !["conflict", "indeterminate", "rejected", "draft-recovery"].includes(blocked.reason) : blocked || snapshot.status !== "ready"))
2098
2176
  return err("unavailable", "State is not editable.");
2099
2177
  try {
2100
2178
  desired = { value: valueClone(codec, value) };
@@ -2102,6 +2180,8 @@ function createIdentityState(options) {
2102
2180
  return err("invalid-input", error instanceof Error ? error.message : "Invalid state.");
2103
2181
  }
2104
2182
  localGeneration++;
2183
+ if (editing && blocked?.conflict)
2184
+ blocked = { ...blocked, conflict: frozen({ ...blocked.conflict, token: newConflictToken(), local: desired.value }) };
2105
2185
  emit();
2106
2186
  schedule();
2107
2187
  return ok(undefined);
@@ -2170,6 +2250,10 @@ function createIdentityState(options) {
2170
2250
  return err("invalid-input", "Draft operation does not match its owner or generation.");
2171
2251
  mutation = frozen(result.value);
2172
2252
  }
2253
+ if (editing) {
2254
+ lastRejection = source.rejection ?? null;
2255
+ rejectedGeneration = source.rejectedGeneration ?? -1;
2256
+ }
2173
2257
  clearTimer();
2174
2258
  base = parsed.value;
2175
2259
  desired = { value: valueClone(codec, source.desired) };
@@ -2345,6 +2429,35 @@ function createStateTransport(options) {
2345
2429
  // client-state-binding-runtime.ts
2346
2430
  var waiting = Object.freeze({ status: "waiting-for-identity" });
2347
2431
  function bindIdentityState(options, retirementOwner) {
2432
+ return bindOwnedIdentityController({
2433
+ ...options,
2434
+ hasPending: (state) => state.status === "ready" ? state.dirty || state.saving : (state.status === "blocked" || state.status === "detached") && state.draft !== null,
2435
+ create: (input) => options.create ? options.create(input) : createIdentityState({
2436
+ address: {
2437
+ instanceId: input.session.instanceId,
2438
+ pluginId: options.resource.pluginId,
2439
+ collection: options.resource.definition.collection,
2440
+ recordId: options.recordId,
2441
+ owner: input.subject.key
2442
+ },
2443
+ expected: { actor: input.session.actor.identity.key, session: input.session.stamp },
2444
+ ownerSession: input.ownerSession,
2445
+ definition: options.resource.definition,
2446
+ transport: createStateTransport({ connection: options.client.connection, resource: options.resource }),
2447
+ ...options.drafts ? { drafts: options.drafts } : {},
2448
+ onConflict: options.onConflict,
2449
+ initializeEmpty: options.initializeEmpty && (options.editPolicy === "collaborators" || input.subject.key === input.session.actor.identity.key),
2450
+ ...options.scheduler ? { scheduler: options.scheduler } : {},
2451
+ currentSession: () => options.client.currentSession()
2452
+ }),
2453
+ preservation: (controller, session) => options.resource && options.drafts ? { storage: options.drafts, scope: {
2454
+ address: controller.getSnapshot().address,
2455
+ actor: session.actor.identity.key,
2456
+ schemaVersion: options.resource.definition.schemaVersion
2457
+ } } : null
2458
+ }, retirementOwner);
2459
+ }
2460
+ function bindOwnedIdentityController(options, retirementOwner) {
2348
2461
  let disposed = false;
2349
2462
  let viewDisposed = false;
2350
2463
  let current = null;
@@ -2360,7 +2473,7 @@ function bindIdentityState(options, retirementOwner) {
2360
2473
  } catch {}
2361
2474
  }
2362
2475
  };
2363
- const hasPending = (state) => state.status === "ready" ? state.dirty || state.saving : (state.status === "blocked" || state.status === "detached") && state.draft !== null;
2476
+ const hasPending = options.hasPending;
2364
2477
  const live = () => {
2365
2478
  if (disposed || viewDisposed)
2366
2479
  return null;
@@ -2454,34 +2567,13 @@ function bindIdentityState(options, retirementOwner) {
2454
2567
  const owner = idCodec("owner-session").decode(`owner-${newOperationId()}`);
2455
2568
  if (!owner.ok)
2456
2569
  throw new Error(owner.error.message);
2457
- const controller = options.create ? options.create({ session: next.session, subject: next.subject, ownerSession: owner.value }) : createIdentityState({
2458
- address: {
2459
- instanceId: next.session.instanceId,
2460
- pluginId: options.resource.pluginId,
2461
- collection: options.resource.definition.collection,
2462
- recordId: options.recordId,
2463
- owner: next.subject.key
2464
- },
2465
- expected: { actor: next.session.actor.identity.key, session: next.session.stamp },
2466
- ownerSession: owner.value,
2467
- definition: options.resource.definition,
2468
- transport: createStateTransport({ connection: options.client.connection, resource: options.resource }),
2469
- ...options.drafts ? { drafts: options.drafts } : {},
2470
- onConflict: options.onConflict,
2471
- initializeEmpty: next.writable && options.initializeEmpty,
2472
- ...options.scheduler ? { scheduler: options.scheduler } : {},
2473
- currentSession: () => options.client.currentSession()
2474
- });
2570
+ const controller = options.create({ session: next.session, subject: next.subject, ownerSession: owner.value });
2475
2571
  if (live()?.key !== next.key) {
2476
2572
  controller.dispose();
2477
2573
  observeAgain = true;
2478
2574
  continue;
2479
2575
  }
2480
- const preservation = options.resource && options.drafts ? { storage: options.drafts, scope: {
2481
- address: controller.getSnapshot().address,
2482
- actor: next.session.actor.identity.key,
2483
- schemaVersion: options.resource.definition.schemaVersion
2484
- } } : null;
2576
+ const preservation = options.preservation?.(controller, next.session) ?? null;
2485
2577
  const entry = { key: next.key, recoveryKey: JSON.stringify([next.session.instanceId, next.session.actor.identity.key, next.subject.key]), owner: owner.value, controller, stop: () => {}, writable: next.writable, reconnect: null, preservation };
2486
2578
  current = entry;
2487
2579
  const update = () => {
@@ -2573,7 +2665,7 @@ function bindIdentityState(options, retirementOwner) {
2573
2665
  if (!valid(entry) || transition.signal.aborted)
2574
2666
  return err("stale-context", "View transition expired.");
2575
2667
  pause = token;
2576
- resume = entry.controller.suspendAutomaticDispatch();
2668
+ resume = entry.controller.suspendAutomaticDispatch?.() ?? (() => {});
2577
2669
  if (transition.pending === "block" && hasPending(entry.controller.getSnapshot()))
2578
2670
  return err("conflict", "New edits arrived before the view transition.");
2579
2671
  const result = transition.pending === "flush" ? await entry.controller.flush() : transition.pending === "preserve" ? await entry.controller.checkpoint() : ok(undefined);