OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / lib / commit-line.js

190 lines · javascript ref:
// commit-line — which signed #commit frames form THE chain, and which are forks of it.
//
// The shared __commit__ log is written by whichever co-owner signs. A network partition elects a signer
// on each side, and both continue from the same head: nextRev(head) is a pure function of the head, so
// they sign the SAME rev over DIFFERENT record sets. Those frames used to share a key (__commit__/<rev>)
// and one silently overwrote the other, so the losing fork left no trace — and a consumer that had taken
// it at the relay never learned the other side's records (hyper-proto-peer test/partition-fork-heal).
//
// Frames are now keyed by rev AND commit CID, so every fork survives, and this module decides, the same
// way on every device:
//   head    the highest rev; on a tie, the lowest commit CID (any deterministic rule works — it only has
//           to agree everywhere)
//   line    every frame that is not a fork of the head's ancestry, in the order below. This is what the
//           membrane projects, what restore() replays, what sign() continues.
//   losers  frames that branched off the line: a frame whose `prev` is the parent of a line frame it is
//           not, and everything descending from such a frame.
//   unhealed  losers no line frame has named in its `heals` list yet — the heal commit's work.
//
// ⛔ ONLY FRAMES MARKED `chain: 1` CAN BE FORKS. Every log written before this module has history this
// rule would misread: re-authored chains that restart with prev=null, prev links that skip, revs that
// fall in seq order. Measured 2026-10-13 on our own served subscribeRepos from cursor 0: wj62 387
// frames with 26 rev drops and 37 prev breaks; loser-club 4512 frames, of which an unmarked rule called
// 4511 "forks". So a frame without the marker is ALWAYS on the line, and the line keeps the order
// firehoseLog always had — seq, ties by rev then CID — so a legacy log projects exactly as before.

const FIREHOSE_PREFIX = '__commit__/'
export const CHAIN_MARK = 1

const marked = (f) => f && f.chain === CHAIN_MARK && !!f.commitCid

// The key a signed frame is stored under. An identical commit signed by two connected co-owners (same
// head, same record set, RFC6979) yields the same CID and therefore the same key — the dedup the
// deterministic rev was built for still holds.
export function frameKey (rev, commitCid) {
  return FIREHOSE_PREFIX + rev + '/' + String(commitCid)
}

function byRevThenCid (a, b) {
  if (a.rev !== b.rev) return a.rev < b.rev ? -1 : 1
  const ac = String(a.commitCid || '')
  const bc = String(b.commitCid || '')
  return ac < bc ? -1 : ac > bc ? 1 : 0
}

// The order firehoseLog has always returned: seq, ties by rev then CID. Two partitioned signers both
// write seq count+1, so a fork and its sibling tie on seq; the loser is excluded, and the heal carries a
// higher seq than either.
function lineOrder (a, b) {
  if ((a.seq || 0) !== (b.seq || 0)) return (a.seq || 0) - (b.seq || 0)
  return byRevThenCid(a, b)
}

export function commitLine (frames) {
  const all = (frames || []).filter((f) => f && f.rev)
  if (all.length === 0) return { head: null, line: [], losers: [], unhealed: [] }

  // The head: highest rev, lowest CID on a tie.
  let head = null
  for (const f of all) {
    if (!head || f.rev > head.rev || (f.rev === head.rev && byRevThenCid(f, head) < 0)) head = f
  }

  const byCid = new Map()
  for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f)

  // The head's ancestry, as far as `prev` links reach inside the log.
  const ancestry = new Set()
  for (let f = head; f && f.commitCid && !ancestry.has(String(f.commitCid));) {
    ancestry.add(String(f.commitCid))
    f = f.prev ? byCid.get(String(f.prev)) : null
  }

  // A fork point is the parent of a MARKED line frame. Genesis frames share the parent "none".
  const parentOf = (f) => (f.prev ? String(f.prev) : '')
  const forkParents = new Set()
  for (const cid of ancestry) { const f = byCid.get(cid); if (marked(f)) forkParents.add(parentOf(f)) }

  // Losers: a MARKED sibling of a marked line frame (same parent, not on the line), then its marked
  // descendants. An unmarked frame is never a loser (see the header).
  const losers = new Set()
  for (const f of all) {
    if (!marked(f) || ancestry.has(String(f.commitCid))) continue
    if (forkParents.has(parentOf(f))) losers.add(String(f.commitCid))
  }
  for (let grew = true; grew;) {
    grew = false
    for (const f of all) {
      if (!marked(f) || losers.has(String(f.commitCid)) || ancestry.has(String(f.commitCid))) continue
      if (f.prev && losers.has(String(f.prev))) { losers.add(String(f.commitCid)); grew = true }
    }
  }

  const line = all.filter((f) => !(f.commitCid && losers.has(String(f.commitCid)))).sort(lineOrder)
  const loserFrames = all.filter((f) => f.commitCid && losers.has(String(f.commitCid))).sort(lineOrder)

  const healed = new Set()
  for (const f of line) for (const cid of (f.heals || [])) healed.add(String(cid))
  const unhealed = loserFrames.filter((f) => !healed.has(String(f.commitCid)))

  return { head, line, losers: loserFrames, unhealed }
}

// healPlan — what the next commit must restate so that EVERY consumer converges, whatever it received.
//
// A consumer behind a relay accepts whatever arrives with a higher rev, so after repeated partitions it
// hops between branches and holds a mix no branch ever had (found by seeded fuzz, 2026-10-13: the
// consumer took two commits of one losing branch, two of another, dropped both heals of the first fork
// because an equal rev had arrived first). The signer cannot know what any consumer received, so a heal
// must not depend on it: it restates the merged truth for every path any frame touched since the fork
// point — `update` for a record that exists, `delete` for one that does not — and records the window it
// asserted (`asserts`, the fork-point rev) and the forks it knew (`heals`).
//
// A heal is EFFECTIVE — no consumer can have skipped it, and it covered everything below it — when no fork
// frame has a rev at or above it, and every fork frame below it is either one it knew or one an earlier
// effective heal covered. Forks covered by an effective heal are done; any other fork reopens a window.
//
// SPLIT HEALS. indigo drops a #commit with more than 200 ops (verify.go MaxCommitOps), so a window wider
// than that is restated over several commits: partial ones carry `restates` (the window's fork-point
// rev), the last carries `asserts`, `heals` and `partsFrom` (the first partial's rev). A partial's paths
// are not restated again — unless a fork frame reaches its rev, because then a consumer may have taken
// the fork and skipped it. For the same reason a split heal is effective only if no fork frame reaches
// its FIRST part.
//
// Returns null (nothing to heal) or { from, paths: Set (still to restate), window: Set (every path of the
// window), heals: [commitCid], partsFrom }.
export function healPlan ({ line, losers }) {
  if (!losers || losers.length === 0) return null
  const all = line.concat(losers)
  const byCid = new Map()
  for (const f of all) if (f.commitCid) byCid.set(String(f.commitCid), f)
  const loserCids = new Set(losers.map((f) => String(f.commitCid)))
  const forkPointRev = (f) => {
    let r = f
    while (r.prev && loserCids.has(String(r.prev))) r = byCid.get(String(r.prev))
    const p = r.prev ? byCid.get(String(r.prev)) : null
    return p ? p.rev : ''
  }

  // Walk the line's asserting heals in order; each effective one covers the forks it knew.
  const covered = new Set()
  for (const h of line) {
    if (h.asserts === undefined) continue
    const knew = new Set((h.heals || []).map(String))
    const reach = h.partsFrom || h.rev
    let effective = true
    for (const l of losers) {
      const c = String(l.commitCid)
      if (l.rev >= reach) { effective = false; break }
      if (!covered.has(c) && !knew.has(c)) { effective = false; break }
    }
    if (effective) for (const c of knew) covered.add(c)
  }

  const open = losers.filter((l) => !covered.has(String(l.commitCid)))
  if (open.length === 0) return null
  let from = null
  for (const l of open) { const r = forkPointRev(l); if (from === null || r < from) from = r }
  const paths = new Set()
  for (const f of all) if (f.rev > from) for (const op of (f.ops || [])) paths.add(op.path)
  const window = new Set(paths)
  // Partials of THIS window that no fork reaches: their paths are done.
  let maxLoserRev = ''
  for (const l of losers) if (l.rev > maxLoserRev) maxLoserRev = l.rev
  let partsFrom = null
  for (const f of line) {
    if (f.restates === undefined || f.restates !== from || f.rev <= maxLoserRev) continue
    if (partsFrom === null || f.rev < partsFrom) partsFrom = f.rev
    // Only update/delete restate a path for every consumer; a `create` in a partial is an ordinary diff op
    // the reference AppView ignores over a stale copy, so that path is still owed.
    for (const op of (f.ops || [])) if (op.action !== 'create') paths.delete(op.path)
  }
  const heals = losers.filter((l) => l.rev > from).map((l) => String(l.commitCid))
  return { from, paths, window, heals, partsFrom }
}

// path -> record CID string after replaying `frames` in order onto `start` (a Map, copied). Uses only the
// CIDs the ops carry, so it needs no record bytes.
export function replayCids (frames, start = new Map()) {
  const m = new Map(start)
  for (const f of frames) {
    for (const op of (f.ops || [])) {
      if (op.action === 'delete') m.delete(op.path)
      else if (op.cid) m.set(op.path, String(op.cid))
    }
  }
  return m
}

export { FIREHOSE_PREFIX }