Files
figcraft/crates/collab/src/diff.rs
T
BFlatandClaude Opus 5.5 4924512f4e M9: collaboration core: ids, change sets, server ordering, convergence (#103)
* M9: real-time collaboration protocol (design)

docs/collab-protocol.md: WebSocket sessions with a server-ordered,
per-property last-writer-wins document sync, client-scoped ids,
fractional positions for children, ephemeral presence (cursors,
selections, follow), per-user undo, hosting from the app first and a
self-hosted server next, and the implementation plan. ROADMAP gains M9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: collaboration core: ids, change sets, server ordering, convergence

Step 1 of docs/collab-protocol.md, no networking:

- Client-scoped node ids: Document::set_client_id makes alloc_id hand out
  client << 32 | counter; single-user files allocate as before; the id
  counter is no longer part of document equality.
- crates/collab (layer 2): the synced object/property model (every saved
  Node field, plus document-level styles, comments, variables, images,
  dev snapshots, library data), Change batches, an O(changed) diff, strict
  and lenient apply, fractional sibling positions, snapshots.
- Server (ordering, validation, rejects with fixes) and Client (optimistic
  view, replay under remote batches, author-side repair of derived data)
  as pure state machines; binary (version byte + postcard, JSON values) and
  JSON message forms, fuzzed decoders.
- Engine: normalize as the recompute hook, collab.diff.
- Tests: round trips over random real commands, an N-client convergence
  fuzz test over delayed in-order links, junk batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: fmt after merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:03:03 +07:00

199 lines
7.6 KiB
Rust

//! Diffing: the changes between a replica's document and an edited version of it.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeId};
use crate::change::{Change, Json, Prop};
use crate::frac::{self, FIRST_KEY, KEY_RESPACE_LEN};
use crate::replica::{Replica, create_change};
use crate::{Error, docprops};
/// The result of [`diff`]: the changes, and the replica of the edited document (with the position
/// keys the changes give it).
#[derive(Clone, Debug)]
pub struct Diff {
/// One batch: field writes, creations (parents first), moves, deletions, then document-level
/// changes. Empty when nothing synced changed.
pub changes: Vec<Change>,
/// The edited document as a replica: what applying `changes` to the original gives.
pub replica: Replica,
}
/// The changes that turn `from`'s document into `to` (an edited version of it, e.g. after a
/// command), at property granularity. Only nodes whose `Arc` differs from `from`'s are looked at,
/// so the cost follows the size of the edit, not of the document. Children that were added, moved
/// or reordered get new position keys; their siblings keep theirs.
pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
let a = from.document();
let ids = to.changed_ids(a, usize::MAX).unwrap_or_default();
let mut positions = from.positions().clone();
let (mut fields, mut added, mut removed) = (Vec::new(), Vec::new(), BTreeSet::new());
// Parents whose children need keys (`None`: the page list).
let mut parents: BTreeSet<Option<NodeId>> = BTreeSet::new();
let mut moved: BTreeSet<NodeId> = BTreeSet::new();
for id in ids {
match (a.node(id), to.node(id)) {
(None, Some(n)) => {
added.push(id);
parents.insert(n.parent);
}
(Some(_), None) => {
removed.insert(id);
}
(Some(o), Some(n)) => {
for p in Prop::FIELDS {
if p.differs(o, n) {
fields.push(Change::Set { id, prop: *p, value: p.get(n)? });
}
}
if o.parent != n.parent {
parents.insert(n.parent);
moved.insert(id);
}
if o.children != n.children {
parents.insert(Some(id));
}
}
(None, None) => {}
}
}
if a.pages != to.pages {
parents.insert(None);
}
let mut rekeyed: BTreeMap<NodeId, String> = BTreeMap::new();
for p in parents {
let kids = match p {
None => &to.pages,
Some(p) => match to.node(p) {
Some(n) => &n.children,
None => continue,
},
};
// A child keeps its key only if it was under the same parent before.
let old = |c: NodeId| a.node(c).filter(|n| n.parent == p).and_then(|_| from.positions().get(&c)).map(String::as_str);
for (c, k) in keys_for(kids, old)? {
positions.insert(c, k.clone());
rekeyed.insert(c, k);
}
}
// Unlisted pages (the library store) still need a key.
for id in &added {
if !rekeyed.contains_key(id) && to.node(*id).is_some_and(|n| n.parent.is_none()) {
positions.insert(*id, FIRST_KEY.to_string());
}
}
let replica = {
let mut doc = to.clone();
doc.published = None;
doc.take_dirty();
for r in &removed {
positions.remove(r);
}
Replica::from_parts(doc, positions)
};
let mut changes = fields;
let new: BTreeSet<NodeId> = added.iter().copied().collect();
// Creations, parents before children.
added.sort_by_cached_key(|id| (depth(to, *id), *id));
for id in &added {
if let Some(n) = to.node(*id) {
changes.push(create_change(n, replica.key(*id))?);
}
}
for (c, k) in &rekeyed {
if new.contains(c) {
continue;
}
if moved.contains(c) {
let parent = to.node(*c).and_then(|n| n.parent);
changes.push(Change::Set { id: *c, prop: Prop::Parent, value: Json::of(&parent)? });
}
changes.push(Change::Set { id: *c, prop: Prop::Position, value: Json::of(k)? });
}
// Deletions last (a layer moved out of a deleted frame has moved by then); one per subtree.
for id in &removed {
if a.node(*id).and_then(|n| n.parent).is_none_or(|p| !removed.contains(&p)) {
changes.push(Change::Delete { id: *id });
}
}
docprops::diff(a, to, &mut changes)?;
Ok(Diff { changes, replica })
}
/// Depth of `id` in `doc` (pages are 0).
fn depth(doc: &Document, id: NodeId) -> usize {
let mut d = 0;
let mut cur = doc.node(id).and_then(|n| n.parent);
while let Some(p) = cur {
d += 1;
if d > 100_000 {
break;
}
cur = doc.node(p).and_then(|n| n.parent);
}
d
}
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
/// longest run of old keys that is still increasing is kept, the rest get keys between their kept
/// neighbours. Falls back to re-spacing every child when keys would get long.
fn keys_for<'a>(kids: &[NodeId], old: impl Fn(NodeId) -> Option<&'a str>) -> Result<Vec<(NodeId, String)>, Error> {
let olds: Vec<Option<&str>> = kids.iter().map(|c| old(*c)).collect();
let kept = increasing_run(&olds);
let mut out = Vec::new();
let mut i = 0;
let mut prev: Option<&str> = None;
while i < kids.len() {
if kept.get(i).copied().unwrap_or(false) {
prev = olds.get(i).copied().flatten();
i += 1;
continue;
}
let start = i;
while i < kids.len() && !kept.get(i).copied().unwrap_or(false) {
i += 1;
}
let next = if i < kids.len() { olds.get(i).copied().flatten() } else { None };
let keys = frac::n_between(prev, next, i - start).map_err(|e| Error::Invalid(e.to_string()))?;
if keys.iter().any(|k| k.len() > KEY_RESPACE_LEN) {
return respace(kids, &olds);
}
out.extend(kids.get(start..i).unwrap_or_default().iter().copied().zip(keys));
}
Ok(out)
}
/// Fresh, short keys for every child (those whose key changes).
fn respace(kids: &[NodeId], olds: &[Option<&str>]) -> Result<Vec<(NodeId, String)>, Error> {
let keys = frac::n_between(None, None, kids.len()).map_err(|e| Error::Invalid(e.to_string()))?;
Ok(kids.iter().zip(olds).zip(keys).filter(|((_, o), k)| **o != Some(k.as_str())).map(|((c, _), k)| (*c, k)).collect())
}
/// Which entries form a longest strictly increasing subsequence of the present keys.
fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
// Patience sorting: `tails[l]` is the index ending the best run of length l + 1.
let mut tails: Vec<usize> = Vec::new();
let mut prev: Vec<Option<usize>> = vec![None; keys.len()];
for (i, k) in keys.iter().enumerate() {
let Some(k) = k else { continue };
let at = tails.partition_point(|t| keys.get(*t).copied().flatten().is_some_and(|tk| tk < *k));
if let Some(p) = prev.get_mut(i) {
*p = at.checked_sub(1).and_then(|b| tails.get(b)).copied();
}
match tails.get_mut(at) {
Some(t) => *t = i,
None => tails.push(i),
}
}
let mut kept = vec![false; keys.len()];
let mut cur = tails.last().copied();
while let Some(i) = cur {
if let Some(k) = kept.get_mut(i) {
*k = true;
}
cur = prev.get(i).copied().flatten();
}
kept
}