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>
This commit is contained in:
BFlat 2026-10-11 01:03:03 +07:00 committed by GitHub
parent 4dd54d19f0
commit 4924512f4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 4179 additions and 55 deletions

49
Cargo.lock generated
View File

@ -702,6 +702,15 @@ dependencies = [
"error-code",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@ -1133,6 +1142,18 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "endi"
version = "1.1.1"
@ -1346,6 +1367,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "figcraft-collab"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"imbl",
"postcard",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-color"
version = "0.2.0"
@ -1372,6 +1408,7 @@ name = "figcraft-engine"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
"figcraft-format",
@ -3185,6 +3222,18 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.6"

View File

@ -25,6 +25,7 @@ figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
figcraft-engine = { path = "crates/engine" }
figcraft-ui-egui = { path = "crates/ui-egui" }
figcraft-mcp = { path = "crates/mcp" }
@ -38,6 +39,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
# rendering / formats
tiny-skia = "0.12"

View File

@ -233,7 +233,15 @@ dimension measured side by side.
landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel;
view a version on the canvas read-only before restoring it; autosave checkpoints every 30
minutes, folded in the panel). Real-time collaboration is designed (`docs/collab-protocol.md`:
WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors).
WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors)
and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved
layer field and the document-level data (styles, variables, comments, images…) as synced
properties, diffs after a command that cost the size of the edit, fractional sibling positions,
a session server that orders and validates batches (cycles, deleted layers, id spaces, sizes)
and a client that shows its own edits at once and replays them over others'; derived data
(auto layout, instances, bindings) is repaired by the author after concurrent edits; binary
messages (a moved layer is 43 bytes, a cursor 23) with a JSON form; a randomized multi-client
test over real commands converges.
Plugins landed: sandboxed WebAssembly plugins (a metered wasmi
interpreter with a memory cap and no WASI, also in the browser) whose API is the command
registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop,
@ -245,7 +253,8 @@ dimension measured side by side.
plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example
plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. *Left:*
plugin-defined UI at run time (forms or panels a plugin returns), plugin settings storage,
distribution. Multiplayer is not built yet. 18–45 h.
distribution; for collaboration, the WebSocket transport, hosting and joining from the app,
live cursors, per-user undo, reconnect, the browser client and a standalone server. 18–45 h.
9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer
pages: engine, render and headless UI-frame timings). The canvas keeps its last image and
re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders
@ -306,8 +315,8 @@ dimension measured side by side.
## Shipped so far
- **Architecture:** 14 crates with enforced layering (`cargo xtask layers`): geom, color, doc,
layout, text, pathops, plugin, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action
- **Architecture:** 15 crates with enforced layering (`cargo xtask layers`): geom, color, doc,
layout, text, pathops, plugin, collab, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action
is a command (~330); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are
one undo step; `command.batch` runs several commands as one step.
- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons,
@ -399,4 +408,4 @@ Weighted feature depth ≈ **79%** (self-graded, from the table).
| M5 Prototype & present | ~82% |
| M6 Interop & dev mode | ~62% |
| M7 Release (signed builds for all platforms, README screenshots) | started |
| M9 Real-time collaboration (WebSocket sessions hosted from the app, then a self-hosted server; live cursors; see `docs/collab-protocol.md`) | protocol designed |
| M9 Real-time collaboration (WebSocket sessions hosted from the app, then a self-hosted server; live cursors; see `docs/collab-protocol.md`) | core done (ids, change sets, server ordering, convergence); networking next |

22
crates/collab/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "figcraft-collab"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft real-time collaboration core: the synced object/property model, change sets, fractional positions, server ordering and validation, client reconciliation and the wire messages (no networking)"
[lints]
workspace = true
[dependencies]
figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
figcraft-geom = { workspace = true }

236
crates/collab/src/change.rs Normal file
View File

@ -0,0 +1,236 @@
//! Changes: the unit of synchronisation. A document is a set of objects (nodes, and document-level
//! data such as styles, variables and comments) with properties; a [`Change`] creates, writes or
//! deletes one of them, and a batch of changes is one user action.
use figcraft_doc::{Node, NodeId};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::Error;
/// A property value. Messages carry it as JSON: the JSON form of a message shows it as is, the
/// binary form as compact JSON text (the document types use tagged and flattened serde
/// representations that only self-describing formats can read back).
#[derive(Clone, Debug, PartialEq)]
pub struct Json(pub serde_json::Value);
impl Json {
/// The JSON value of `v`.
pub fn of<T: Serialize + ?Sized>(v: &T) -> Result<Json, Error> {
serde_json::to_value(v).map(Json).map_err(|e| Error::Encode(e.to_string()))
}
/// Read the value as a `T` (what [`apply`](crate::Replica::apply) does to check a value's type).
pub fn read<'a, T: Deserialize<'a>>(&'a self, what: &str) -> Result<T, Error> {
T::deserialize(&self.0).map_err(|e| Error::Invalid(format!("{what}: {e}")))
}
}
impl Serialize for Json {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if s.is_human_readable() {
self.0.serialize(s)
} else {
let text = serde_json::to_string(&self.0).map_err(serde::ser::Error::custom)?;
s.serialize_str(&text)
}
}
}
impl<'de> Deserialize<'de> for Json {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Text;
impl serde::de::Visitor<'_> for Text {
type Value = Json;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("JSON text")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Json, E> {
// serde_json caps nesting depth, so hostile input can't overflow the stack.
serde_json::from_str(v).map(Json).map_err(E::custom)
}
}
if d.is_human_readable() { serde_json::Value::deserialize(d).map(Json) } else { d.deserialize_str(Text) }
}
}
/// One change to the document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Change {
/// A new node (or one brought back, e.g. by undo): its type (the JSON of
/// [`NodeKind`](figcraft_doc::NodeKind)), place in the tree (pages have no parent) and every
/// property that differs from a new node of that type.
Create { id: NodeId, kind: Json, parent: Option<NodeId>, position: String, props: Vec<(Prop, Json)> },
/// Property `prop` of node `id` is now `value`.
Set { id: NodeId, prop: Prop, value: Json },
/// Delete a node and everything inside it (deleting one that is already gone does nothing).
Delete { id: NodeId },
/// A document-level property is now `value`.
SetDoc { prop: DocProp, value: Json },
/// A document-level entry (a style, comment, variable…) is now `value` (added or replaced).
Put { entry: Entry, value: Json },
/// Remove a document-level entry (removing one that is already gone does nothing).
Remove { entry: Entry },
}
impl Change {
/// The node this change is about, if any.
pub fn node(&self) -> Option<NodeId> {
match self {
Change::Create { id, .. } | Change::Set { id, .. } | Change::Delete { id } => Some(*id),
_ => None,
}
}
}
macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole. New properties go at the end (the binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
/// The parent node (`null` for pages).
Parent,
/// The fractional position key among the parent's children ([`frac`](crate::frac)).
Position,
$($variant),*
}
impl Prop {
/// The properties that are plain fields of [`Node`] (everything but the tree position).
pub const FIELDS: &'static [Prop] = &[$(Prop::$variant),*];
/// Whether field property `self` differs between `a` and `b` (tree properties: false).
pub(crate) fn differs(self, a: &Node, b: &Node) -> bool {
match self {
Prop::Parent | Prop::Position => false,
$(Prop::$variant => a.$field != b.$field),*
}
}
/// Field property `self` of `n`.
pub(crate) fn get(self, n: &Node) -> Result<Json, Error> {
match self {
Prop::Parent | Prop::Position => Err(Error::Invalid(format!("{self:?} is not a field"))),
$(Prop::$variant => Json::of(&n.$field)),*
}
}
/// Write field property `self` of `n` (unchanged when `v` has the wrong type).
pub(crate) fn set(self, n: &mut Node, v: &Json) -> Result<(), Error> {
match self {
Prop::Parent | Prop::Position => return Err(Error::Invalid(format!("{self:?} is not a field"))),
$(Prop::$variant => n.$field = v.read(stringify!($field))?),*
}
Ok(())
}
}
/// Fails to build when `Node` gains a field that isn't listed as a property above.
#[allow(dead_code)]
fn every_node_field_is_a_property(n: &Node) {
let Node { id: _, parent: _, children: _, $($field: _),* } = n;
}
};
}
node_props! {
Name => name,
Kind => kind,
Visible => visible,
Locked => locked,
Opacity => opacity,
Blend => blend,
Transform => transform,
Width => width,
Height => height,
Fills => fills,
Strokes => strokes,
Stroke => stroke,
Effects => effects,
Corners => corners,
Constraints => constraints,
Layout => layout,
ChildLayout => child_layout,
Clip => clip,
IsMask => is_mask,
MaskType => mask_type,
Grids => grids,
Guides => guides,
Exports => exports,
Interactions => interactions,
Overflow => overflow,
ScrollPosition => scroll_position,
Styles => styles,
Bound => bound,
VarModes => var_modes,
ConstrainProportions => constrain_proportions,
Source => source,
Exposed => exposed,
DevStatus => dev_status,
DevChanged => dev_changed,
DevNote => dev_note,
Overrides => overrides,
PropRefs => prop_refs,
PluginData => plugin_data,
Annotations => annotations,
}
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
/// variables) sync per entry ([`Entry`]) plus their order; the whole-list properties are the
/// fallback for lists whose ids aren't unique.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DocProp {
/// The file name.
Name,
/// The page shown when the file opens.
CurrentPage,
/// Colours saved as document colours.
DocumentColors,
/// The colour space colour values are in.
ColorProfile,
/// Saved image adjustment presets.
ImagePresets,
/// Annotation categories (`null`: the defaults).
AnnotationCategories,
/// Enabled libraries and the assets copied from them (whole).
Libraries,
/// Every style, in order (fallback).
Styles,
/// Style ids in panel order.
StyleOrder,
/// Every comment thread, in order (fallback).
Comments,
/// Comment ids in order.
CommentOrder,
/// Every variable collection, in order (fallback).
Collections,
/// Collection ids in order.
CollectionOrder,
/// Every variable, in order (fallback).
Variables,
/// Variable ids in order.
VariableOrder,
}
/// A document-level entry, by id.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Entry {
/// A style (value: [`Style`](figcraft_doc::Style)).
Style(String),
/// A comment thread (value: [`Comment`](figcraft_doc::Comment)).
Comment(u64),
/// A variable collection (value: [`Collection`](figcraft_doc::Collection)).
Collection(String),
/// A variable (value: [`Variable`](figcraft_doc::Variable)).
Variable(String),
/// What a ready-for-dev layer looked like when marked (value:
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
Image(String),
}

244
crates/collab/src/client.rs Normal file
View File

@ -0,0 +1,244 @@
//! The client side: the confirmed server state, this client's unconfirmed batches on top of it,
//! and the optimistic view the user edits.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use figcraft_doc::{Document, NodeId};
use crate::change::{Change, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
use crate::replica::{Mode, Replica};
use crate::{Error, Recompute, diff};
/// A batch sent and not yet confirmed.
#[derive(Clone, Debug)]
struct Pending {
batch_id: u64,
changes: Vec<Change>,
/// Someone else's batch was ordered before this one: its derived data may be stale.
rebased: bool,
}
/// One participant's sync state.
#[derive(Clone, Debug)]
pub struct Client {
id: u32,
/// The server's state as of `seq`.
confirmed: Replica,
seq: u64,
pending: VecDeque<Pending>,
/// `confirmed` with the pending batches applied: what the user sees and edits.
view: Replica,
next_batch: u64,
/// Ids below this were handed out; rebuilding the view never hands them out again.
next_id: NodeId,
peers: BTreeMap<u32, Peer>,
presence: BTreeMap<u32, Presence>,
}
impl Client {
/// The first message of a connection.
pub fn hello(file: &str, token: &str, name: &str, color: &str) -> ClientMsg {
ClientMsg::Hello(Hello {
protocol: PROTOCOL,
file: file.to_string(),
token: token.to_string(),
name: name.to_string(),
color: color.to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
})
}
/// Start from the server's `Welcome`.
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { client_id, seq, snapshot, peers } = welcome else {
return Err(Error::Protocol("expected a welcome".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
confirmed.document_mut().set_client_id(client_id);
let next_id = confirmed.document().next_id();
Ok(Client {
id: client_id,
view: confirmed.clone(),
confirmed,
seq,
pending: VecDeque::new(),
next_batch: 1,
next_id,
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
presence: BTreeMap::new(),
})
}
/// This client's id (its node ids are `id << 32 | counter`).
pub fn id(&self) -> u32 {
self.id
}
/// The document as this user sees it (server state plus their unconfirmed edits). Edit a copy
/// (it allocates ids in this client's space) and hand it to [`edit`](Self::edit).
pub fn document(&self) -> &Document {
self.view.document()
}
/// The optimistic state (document and positions).
pub fn view(&self) -> &Replica {
&self.view
}
/// The last confirmed server state.
pub fn confirmed(&self) -> &Replica {
&self.confirmed
}
/// The sequence number of the last server batch applied.
pub fn seq(&self) -> u64 {
self.seq
}
/// Batches sent but not yet confirmed or rejected.
pub fn pending(&self) -> usize {
self.pending.len()
}
/// The other people in the session.
pub fn peers(&self) -> impl Iterator<Item = &Peer> {
self.peers.values()
}
/// The last presence each peer sent.
pub fn presence_of(&self, id: u32) -> Option<&Presence> {
self.presence.get(&id)
}
/// The user edited the document (`doc` is [`document`](Self::document) after a command): show
/// it at once and return the batch to send, if anything synced changed.
pub fn edit(&mut self, doc: &Document) -> Result<Option<ClientMsg>, Error> {
let d = diff(&self.view, doc)?;
if d.changes.is_empty() {
return Ok(None);
}
self.view = d.replica;
self.keep_ids();
Ok(Some(self.queue(d.changes)))
}
/// Queue `changes` as a new batch (already shown in the view) and make its message.
fn queue(&mut self, changes: Vec<Change>) -> ClientMsg {
let batch_id = self.next_batch;
self.next_batch += 1;
self.pending.push_back(Pending { batch_id, changes: changes.clone(), rebased: false });
ClientMsg::Changes { base_seq: self.seq, batch_id, changes }
}
/// Keep allocating in this client's space, after every id already handed out.
fn keep_ids(&mut self) {
let doc = self.view.document_mut();
if doc.client_id() != self.id {
doc.set_client_id(self.id);
}
if doc.next_id() < self.next_id {
doc.reserve_ids(NodeId(self.next_id.0.saturating_sub(1)));
}
self.next_id = doc.next_id();
}
/// Handle a message from the server. Returns messages to send: a follow-up batch when this
/// client's confirmed batch was ordered after someone else's and `recompute` changes derived
/// data over it. Errors mean the connection is unusable (rejoin).
pub fn receive(&mut self, msg: ServerMsg, recompute: &dyn Recompute) -> Result<Vec<ClientMsg>, Error> {
match msg {
ServerMsg::Changes { seq, author, batch_id, changes } => {
if seq != self.seq + 1 {
return Err(Error::Protocol(format!("sequence {seq} after {}", self.seq)));
}
if author == self.id {
let mine = self.pending.pop_front().ok_or_else(|| Error::Protocol("confirmation of an unknown batch".into()))?;
if mine.batch_id != batch_id {
return Err(Error::Protocol(format!("batch {batch_id} confirmed, expected {}", mine.batch_id)));
}
let before = self.confirmed.clone();
let applied = self.confirmed.apply(&changes, Mode::Strict)?;
self.seq = seq;
if mine.rebased {
return self.repair(&before, &applied.touched, recompute);
}
Ok(Vec::new())
} else {
self.confirmed.apply(&changes, Mode::Strict)?;
self.seq = seq;
for p in &mut self.pending {
p.rebased = true;
}
self.rebuild();
Ok(Vec::new())
}
}
ServerMsg::Reject { batch_id, .. } => {
// The confirmed state is what the server has: drop the batch and replay the rest
// (the fixes are for clients that keep no confirmed copy).
let mine = self.pending.pop_front().ok_or_else(|| Error::Protocol("rejection of an unknown batch".into()))?;
if mine.batch_id != batch_id {
return Err(Error::Protocol(format!("batch {batch_id} rejected, expected {}", mine.batch_id)));
}
self.rebuild();
Ok(Vec::new())
}
ServerMsg::Presence { client_id, presence } => {
self.presence.insert(client_id, presence);
Ok(Vec::new())
}
ServerMsg::PeerJoined(p) => {
self.peers.insert(p.client_id, p);
Ok(Vec::new())
}
ServerMsg::PeerLeft { client_id } => {
self.peers.remove(&client_id);
self.presence.remove(&client_id);
Ok(Vec::new())
}
ServerMsg::Pong => Ok(Vec::new()),
ServerMsg::Welcome { .. } => Err(Error::Protocol("already joined".into())),
ServerMsg::Refused { reason } => Err(Error::Protocol(reason)),
}
}
/// The view is the confirmed state with the pending batches replayed on top (changes that no
/// longer apply are skipped; the server will reject their batch).
fn rebuild(&mut self) {
let mut v = self.confirmed.clone();
for p in &self.pending {
// Lenient application never fails.
let _ = v.apply(&p.changes, Mode::Lenient);
}
self.view = v;
self.keep_ids();
}
/// This client's batch landed after someone else's: recompute derived data over what it
/// touched and send what changes (minus properties a later pending batch writes anyway).
fn repair(&mut self, before: &Replica, touched: &BTreeSet<NodeId>, recompute: &dyn Recompute) -> Result<Vec<ClientMsg>, Error> {
let mut work = self.confirmed.document().clone();
// Ids the recompute makes must not collide with those of later pending batches.
work.reserve_ids(NodeId(self.next_id.0.saturating_sub(1)));
recompute.recompute(&mut work, before.document(), touched);
let fix = diff(&self.confirmed, &work)?;
let later: BTreeSet<(NodeId, Prop)> = self
.pending
.iter()
.flat_map(|p| &p.changes)
.filter_map(|c| match c {
Change::Set { id, prop, .. } => Some((*id, *prop)),
_ => None,
})
.collect();
let changes: Vec<Change> =
fix.changes.into_iter().filter(|c| !matches!(c, Change::Set { id, prop, .. } if later.contains(&(*id, *prop)))).collect();
if changes.is_empty() {
return Ok(Vec::new());
}
let _ = self.view.apply(&changes, Mode::Lenient);
self.keep_ids();
Ok(vec![self.queue(changes)])
}
}

View File

@ -0,0 +1,160 @@
//! Message encodings: round trips, hostile input, and the sizes of typical messages.
use figcraft_doc::{Document, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json};
use crate::sim::Rng;
use crate::{Error, Replica, diff};
/// A typical edit: one layer dragged (its transform).
fn moved_layer() -> ClientMsg {
let mut doc = Document::new("f");
let page = doc.first_page().unwrap();
doc.set_client_id(3);
let r = doc.create(page, NodeKind::Rectangle, "Rectangle 1", 120.0, 80.0, 100.0, 60.0).unwrap();
let a = Replica::new(doc);
let mut b = a.document().clone();
b.get_mut(r).unwrap().transform = figcraft_geom::Affine::translate((164.5, 92.25));
ClientMsg::Changes { base_seq: 1234, batch_id: 56, changes: diff(&a, &b).unwrap().changes }
}
fn cursor() -> ClientMsg {
ClientMsg::Presence(Presence { cursor: Some((NodeId(1), 512.5, 384.25)), ..Default::default() })
}
fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
let peer = Peer { client_id: 4, name: "Ada".into(), color: "#FF8800".into() };
let set = Change::Set { id: NodeId((4 << 32) | 9), prop: Prop::Name, value: Json::of("Café ☕").unwrap() };
let client = vec![
ClientMsg::Hello(Hello {
protocol: PROTOCOL,
file: "f".into(),
token: "t".into(),
name: "Ada".into(),
color: "#FF8800".into(),
client_version: "0.2.0".into(),
}),
moved_layer(),
cursor(),
ClientMsg::Presence(Presence {
cursor: None,
selection: Some(vec![NodeId(2), NodeId(3)]),
viewport: Some((NodeId(1), 0.0, 0.0, 1200.0, 800.0)),
editing_text: Some(NodeId(7)),
}),
ClientMsg::Ack { seq: 9 },
ClientMsg::Ping,
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
let server = vec![
ServerMsg::Welcome { client_id: 4, seq: 10, snapshot, peers: vec![peer.clone()] },
ServerMsg::Changes { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] },
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
ServerMsg::Presence { client_id: 4, presence: Presence::default() },
ServerMsg::PeerJoined(peer),
ServerMsg::PeerLeft { client_id: 4 },
ServerMsg::Refused { reason: "full".into() },
ServerMsg::Pong,
];
(client, server)
}
#[test]
fn every_message_round_trips_in_both_forms() {
let (client, server) = samples();
for m in &client {
assert_eq!(&decode::<ClientMsg>(&encode(m).unwrap(), usize::MAX).unwrap(), m);
assert_eq!(&decode_json::<ClientMsg>(&encode_json(m).unwrap(), usize::MAX).unwrap(), m);
}
for m in &server {
assert_eq!(&decode::<ServerMsg>(&encode(m).unwrap(), usize::MAX).unwrap(), m);
assert_eq!(&decode_json::<ServerMsg>(&encode_json(m).unwrap(), usize::MAX).unwrap(), m);
}
// The JSON form is readable: the type, then its fields; values as JSON.
let json = encode_json(&moved_layer()).unwrap();
assert!(json.starts_with(r#"{"changes":{"base_seq":1234,"batch_id":56,"changes":[{"set":"#), "{json}");
assert!(json.contains(r#""prop":"transform","value":[1.0,0.0,0.0,1.0,164.5,92.25]"#), "{json}");
// The binary form starts with the wire version and carries values as JSON text.
let bin = encode(&moved_layer()).unwrap();
assert_eq!(bin[0], WIRE_VERSION);
assert!(bin.windows(5).any(|w| w == b"164.5"));
}
#[test]
fn hostile_bytes_are_refused_without_panicking() {
assert!(matches!(decode::<ClientMsg>(&[], 100), Err(Error::Decode(_))));
assert!(matches!(decode::<ClientMsg>(&[WIRE_VERSION + 1, 4], 100), Err(Error::Decode(_))));
let ping = encode(&ClientMsg::Ping).unwrap();
let mut trailing = ping.clone();
trailing.push(0);
assert!(decode::<ClientMsg>(&trailing, 100).is_err(), "trailing bytes");
assert!(decode::<ClientMsg>(&ping, 1).is_err(), "over the size limit");
assert!(decode_json::<ClientMsg>("{\"ping\":null}", 3).is_err(), "over the size limit");
// A huge length prefix doesn't allocate it.
let mut huge = vec![WIRE_VERSION, 1];
huge.extend([0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
assert!(decode::<ClientMsg>(&huge, 1 << 20).is_err());
// Deeply nested JSON inside a value is refused by the JSON parser's depth limit.
let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
let evil = format!(r#"{{"changes":{{"base_seq":0,"batch_id":0,"changes":[{{"set":{{"id":1,"prop":"name","value":{deep}}}}}]}}}}"#);
assert!(decode_json::<ClientMsg>(&evil, usize::MAX).is_err());
// The same value in a binary message (JSON text inside).
let mut bin = encode(&ClientMsg::Changes {
base_seq: 0,
batch_id: 0,
changes: vec![Change::Set { id: NodeId(1), prop: Prop::Name, value: Json::of("x").unwrap() }],
})
.unwrap();
let at = bin.windows(3).position(|w| w == b"\"x\"").unwrap();
bin.truncate(at - 1);
let mut len = deep.len();
while len >= 0x80 {
bin.push((len as u8 & 0x7F) | 0x80);
len >>= 7;
}
bin.push(len as u8);
bin.extend(deep.as_bytes());
assert!(matches!(decode::<ClientMsg>(&bin, usize::MAX), Err(Error::Decode(_))));
}
#[test]
fn fuzzed_messages_never_panic() {
let (client, server) = samples();
let valid: Vec<Vec<u8>> = client.iter().map(|m| encode(m).unwrap()).chain(server.iter().map(|m| encode(m).unwrap())).collect();
let mut rng = Rng::new(42);
for round in 0..20_000 {
let mut bytes =
if round % 4 == 0 { (0..rng.below(64)).map(|_| rng.next_u64() as u8).collect() } else { rng.pick(&valid).cloned().unwrap_or_default() };
// Flip, insert or cut a few bytes.
for _ in 0..1 + rng.below(4) {
if bytes.is_empty() {
break;
}
let i = rng.below(bytes.len());
match rng.below(3) {
0 => bytes[i] = rng.next_u64() as u8,
1 => bytes.insert(i, rng.next_u64() as u8),
_ => bytes.truncate(i.max(1)),
}
}
let _ = decode::<ClientMsg>(&bytes, 1 << 20);
let _ = decode::<ServerMsg>(&bytes, 1 << 20);
if let Ok(text) = std::str::from_utf8(&bytes) {
let _ = decode_json::<ClientMsg>(text, 1 << 20);
}
}
}
#[test]
fn message_sizes() {
// Recorded in docs/collab-protocol.md (Encoding). Binary stays well under JSON.
let edit = moved_layer();
let (eb, ej) = (encode(&edit).unwrap().len(), encode_json(&edit).unwrap().len());
let cur = cursor();
let (cb, cj) = (encode(&cur).unwrap().len(), encode_json(&cur).unwrap().len());
println!("typical edit (move a layer): binary {eb} B, JSON {ej} B; cursor update: binary {cb} B, JSON {cj} B");
assert!(eb < ej && cb < cj);
assert!(cb <= 32, "a cursor update is tiny: {cb}");
assert!(eb <= 96, "a move is small: {eb}");
}

198
crates/collab/src/diff.rs Normal file
View File

@ -0,0 +1,198 @@
//! 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
}

View File

@ -0,0 +1,246 @@
//! Diffing and applying documents directly (the engine-level round trips run real commands).
use std::collections::BTreeMap;
use std::sync::Arc;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Comment, DevSnapshot, Document, ImageData, Message, Node, NodeId, NodeKind, Style, StyleKind};
use crate::change::{Change, DocProp, Entry, Json, Prop};
use crate::replica::{Mode, Replica};
use crate::{Error, diff, message};
/// `apply(diff(a, b), a) == b`, positions included, also after an encode/decode round trip.
fn round_trip(a: &Replica, b: &Document) -> Vec<Change> {
let d = diff(a, b).unwrap();
let mut r = a.clone();
r.apply(&d.changes, Mode::Strict).unwrap();
assert_eq!(r.document(), b, "{:#?}", d.changes);
assert_eq!(r, d.replica, "positions agree");
let wire: Vec<Change> = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap();
assert_eq!(wire, d.changes, "binary round trip");
let json: Vec<Change> = message::decode_json(&message::encode_json(&d.changes).unwrap(), usize::MAX).unwrap();
assert_eq!(json, d.changes, "JSON round trip");
d.changes
}
fn rect(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
doc.create(parent, NodeKind::Rectangle, name, 0.0, 0.0, 10.0, 10.0).unwrap()
}
fn setup() -> (Replica, NodeId, NodeId, Vec<NodeId>) {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 200.0).unwrap();
let kids = (0..5).map(|i| rect(&mut doc, frame, &format!("R{i}"))).collect();
(Replica::new(doc), page, frame, kids)
}
#[test]
fn property_edits_send_only_what_changed() {
let (a, _, _, kids) = setup();
let mut b = a.document().clone();
b.get_mut(kids[1]).unwrap().opacity = 0.5;
b.get_mut(kids[1]).unwrap().fills = vec![Paint::solid(Color::from_rgb8_const(1, 2, 3))];
let changes = round_trip(&a, &b);
assert_eq!(changes.len(), 2);
assert!(changes.iter().all(|c| matches!(c, Change::Set { id, prop: Prop::Opacity | Prop::Fills, .. } if *id == kids[1])));
assert!(round_trip(&a, a.document()).is_empty(), "nothing changed");
}
#[test]
fn creations_moves_reorders_and_deletions_round_trip() {
let (a, page, frame, kids) = setup();
let mut b = a.document().clone();
// A new group with a new child inside the frame, one rectangle moved into it, one deleted,
// one reordered to the bottom, one moved to the page.
let g = b.create(frame, NodeKind::Group, "G", 5.0, 5.0, 1.0, 1.0).unwrap();
let inner = rect(&mut b, g, "Inner");
b.place(kids[0], g, 0).unwrap();
b.remove(kids[1]).unwrap();
b.reorder(kids[4], 0).unwrap();
b.place(kids[2], page, 0).unwrap();
let changes = round_trip(&a, &b);
let creates: Vec<NodeId> = changes.iter().filter_map(|c| if let Change::Create { id, .. } = c { Some(*id) } else { None }).collect();
assert_eq!(creates, vec![g, inner], "parents before children");
assert!(changes.contains(&Change::Delete { id: kids[1] }));
// kids[3] kept its place and key.
assert!(!changes.iter().any(|c| c.node() == Some(kids[3])));
}
#[test]
fn deleting_a_subtree_sends_one_delete_and_moves_out_first() {
let (a, page, frame, kids) = setup();
let mut b = a.document().clone();
b.place(kids[0], page, 0).unwrap();
b.remove(frame).unwrap();
let changes = round_trip(&a, &b);
let deletes: Vec<&Change> = changes.iter().filter(|c| matches!(c, Change::Delete { .. })).collect();
assert_eq!(deletes, vec![&Change::Delete { id: frame }]);
let moved = changes.iter().position(|c| matches!(c, Change::Set { id, prop: Prop::Parent, .. } if *id == kids[0])).unwrap();
let deleted = changes.iter().position(|c| matches!(c, Change::Delete { .. })).unwrap();
assert!(moved < deleted);
}
#[test]
fn pages_add_reorder_and_delete() {
let (a, page, _, _) = setup();
let mut b = a.document().clone();
let p2 = b.add_page("Two");
let p3 = b.add_page("Three");
rect(&mut b, p3, "On three");
b.pages = vec![p3, page, p2];
let after = round_trip(&a, &b);
assert!(after.iter().any(|c| matches!(c, Change::Create { id, parent: None, .. } if *id == p3)));
let a2 = diff(&a, &b).unwrap().replica;
let mut c = b.clone();
c.remove(page).unwrap();
round_trip(&a2, &c);
}
#[test]
fn kind_changes_and_transforms_round_trip() {
let (a, _, frame, kids) = setup();
let mut b = a.document().clone();
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new() };
b.get_mut(kids[0]).unwrap().kind = NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.25 };
b.get_mut(kids[0]).unwrap().transform = figcraft_geom::Affine::new([0.5, 0.1, -0.1, 2.0, 1.0 / 3.0, -7.25]);
round_trip(&a, &b);
}
fn style(id: &str) -> Style {
Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } }
}
#[test]
fn document_level_data_round_trips() {
let (a, page, _, kids) = setup();
let mut b = a.document().clone();
b.name = "Renamed".into();
b.document_colors.push(Color::from_rgb8_const(9, 9, 9));
b.styles = vec![style("s1"), style("s2"), style("s3")];
b.comments.push(Comment {
id: 7,
page,
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
resolved: false,
replies: vec![],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
let snap = DevSnapshot { nodes: vec![Arc::new(b.node(kids[0]).unwrap().clone())] };
b.dev_snapshots.insert(kids[0], Arc::new(snap));
let changes = round_trip(&a, &b);
assert!(changes.contains(&Change::SetDoc { prop: DocProp::Name, value: Json::of("Renamed").unwrap() }));
let a2 = diff(&a, &b).unwrap().replica;
// Edit one style, remove one, reorder: per-entry changes plus the order.
let mut c = b.clone();
c.styles[0].name = "First".into();
c.styles.remove(1);
c.styles.reverse();
c.images.clear();
c.comments[0].resolved = true;
let changes = round_trip(&a2, &c);
assert!(changes.contains(&Change::Remove { entry: Entry::Style("s2".into()) }));
assert!(changes.contains(&Change::Remove { entry: Entry::Image("hash".into()) }));
assert!(changes.iter().any(|ch| matches!(ch, Change::SetDoc { prop: DocProp::StyleOrder, .. })));
// Duplicate ids fall back to the whole list.
let a3 = diff(&a2, &c).unwrap().replica;
let mut d = c.clone();
d.styles.push(style("s1"));
let changes = round_trip(&a3, &d);
assert!(matches!(changes.as_slice(), [Change::SetDoc { prop: DocProp::Styles, .. }]));
}
#[test]
fn snapshots_rebuild_the_same_state() {
let (a, page, frame, _) = setup();
let mut b = a.document().clone();
b.add_page("Second");
b.name = "Snap".into();
b.reorder(frame, 0).unwrap();
rect(&mut b, page, "Top");
b.styles.push(style("s"));
let r = diff(&a, &b).unwrap().replica;
let snap = r.snapshot().unwrap();
let back = Replica::from_snapshot(&snap).unwrap();
assert_eq!(back, r);
assert!(Replica::from_snapshot(&[]).is_err(), "a snapshot needs a page");
}
#[test]
fn diffing_costs_the_size_of_the_edit() {
let mut doc = Document::new("Big");
let page = doc.first_page().unwrap();
let ids: Vec<NodeId> = (0..20_000).map(|i| rect(&mut doc, page, &format!("R{i}"))).collect();
let a = Replica::new(doc);
let mut b = a.document().clone();
b.get_mut(ids[123]).unwrap().name = "Edited".into();
let t = std::time::Instant::now();
let changes = round_trip(&a, &b);
assert_eq!(changes.len(), 1);
// Inserting one layer in the middle writes one key; its siblings keep theirs.
let mut c = a.document().clone();
let n = Node::new(c.alloc_id(), NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 }, "Mid", 1.0, 1.0);
c.insert(n, page, Some(10_000)).unwrap();
let changes = round_trip(&a, &c);
assert_eq!(changes.len(), 1);
assert!(t.elapsed().as_secs_f64() < 5.0, "debug builds included");
}
#[test]
fn keys_respace_before_they_grow_long() {
let (mut a, _, frame, _) = setup();
// Keep inserting at the same spot: keys would grow, so the parent is re-spaced now and then.
for i in 0..400 {
let mut b = a.document().clone();
let n = Node::new(b.alloc_id(), NodeKind::Rectangle, format!("N{i}"), 1.0, 1.0);
b.insert(n, frame, Some(1)).unwrap();
round_trip(&a, &b);
a = diff(&a, &b).unwrap().replica;
}
assert!(a.positions().values().all(|k| k.len() <= crate::frac::KEY_RESPACE_LEN));
}
#[test]
fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let (a, page, frame, kids) = setup();
let set = |id: NodeId, prop: Prop, v: serde_json::Value| Change::Set { id, prop, value: Json(v) };
let frame_kind = || Json::of(&NodeKind::Frame).unwrap();
let bad: Vec<(Change, &str)> = vec![
(set(NodeId(999), Prop::Name, "x".into()), "unknown node"),
(set(kids[0], Prop::Opacity, "loud".into()), "wrong type"),
(set(kids[0], Prop::Opacity, 2.0.into()), "out of range"),
(set(kids[0], Prop::Width, (-1.0).into()), "negative size"),
(set(frame, Prop::Parent, serde_json::json!(kids[0])), "into a leaf"),
(set(frame, Prop::Parent, serde_json::Value::Null), "a frame without a parent"),
(set(page, Prop::Parent, serde_json::json!(frame)), "a page into a frame"),
(set(kids[0], Prop::Position, "not a key".into()), "bad key"),
(set(kids[0], Prop::Kind, serde_json::json!({"type": "page"})), "becoming a page"),
(set(frame, Prop::Kind, serde_json::json!({"type": "rectangle"})), "a leaf with children"),
(Change::Create { id: kids[0], kind: frame_kind(), parent: Some(page), position: "a5".into(), props: vec![] }, "existing id"),
(Change::Create { id: NodeId(500), kind: frame_kind(), parent: None, position: "a5".into(), props: vec![] }, "parentless frame"),
(Change::Create { id: NodeId(500), kind: frame_kind(), parent: Some(NodeId(404)), position: "a5".into(), props: vec![] }, "missing parent"),
(Change::Delete { id: page }, "the last page"),
(Change::Put { entry: Entry::Style("a".into()), value: Json::of(&style("b")).unwrap() }, "entry id mismatch"),
];
for (c, why) in bad {
let mut r = a.clone();
assert!(r.apply(std::slice::from_ref(&c), Mode::Strict).is_err(), "{why}: {c:?}");
let mut r = a.clone();
let out = r.apply(&[c, set(kids[0], Prop::Name, "ok".into())], Mode::Lenient).unwrap();
assert!(out.skipped <= 1, "{why}");
assert_eq!(r.document().node(kids[0]).unwrap().name, "ok");
}
// A cycle.
let mut r = a.clone();
let inner = Change::Create { id: NodeId(600), kind: frame_kind(), parent: Some(frame), position: "a9".into(), props: vec![] };
let cycle = set(frame, Prop::Parent, serde_json::json!(600));
assert!(matches!(r.apply(&[inner, cycle], Mode::Strict), Err(Error::Invalid(_))));
// Deleting what's already gone does nothing.
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}

View File

@ -0,0 +1,340 @@
//! Document-level objects: diffing and applying the data that isn't a node (file name, styles,
//! variables, comments, images, dev snapshots, library data…).
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Json};
/// An entry of a document-level list that has an id.
trait Keyed: Clone + PartialEq + Serialize + DeserializeOwned {
type Key: Ord + Clone + Serialize + DeserializeOwned;
fn key(&self) -> Self::Key;
}
impl Keyed for Style {
type Key = String;
fn key(&self) -> String {
self.id.clone()
}
}
impl Keyed for Comment {
type Key = u64;
fn key(&self) -> u64 {
self.id
}
}
impl Keyed for Collection {
type Key = String;
fn key(&self) -> String {
self.id.clone()
}
}
impl Keyed for Variable {
type Key = String;
fn key(&self) -> String {
self.id.clone()
}
}
/// How one keyed list of the document syncs.
struct List<T: Keyed> {
whole: DocProp,
order: DocProp,
entry: fn(T::Key) -> Entry,
get: fn(&Document) -> &Vec<T>,
get_mut: fn(&mut Document) -> &mut Vec<T>,
}
const STYLES: List<Style> =
List { whole: DocProp::Styles, order: DocProp::StyleOrder, entry: Entry::Style, get: |d| &d.styles, get_mut: |d| &mut d.styles };
const COMMENTS: List<Comment> =
List { whole: DocProp::Comments, order: DocProp::CommentOrder, entry: Entry::Comment, get: |d| &d.comments, get_mut: |d| &mut d.comments };
const COLLECTIONS: List<Collection> = List {
whole: DocProp::Collections,
order: DocProp::CollectionOrder,
entry: Entry::Collection,
get: |d| &d.variables.collections,
get_mut: |d| &mut d.variables.collections,
};
const VARIABLES: List<Variable> = List {
whole: DocProp::Variables,
order: DocProp::VariableOrder,
entry: Entry::Variable,
get: |d| &d.variables.variables,
get_mut: |d| &mut d.variables.variables,
};
impl<T: Keyed> List<T> {
fn diff(&self, a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<(), Error> {
let (la, lb) = ((self.get)(a), (self.get)(b));
if la == lb {
return Ok(());
}
let unique = |v: &[T]| {
let mut seen = BTreeSet::new();
v.iter().all(|t| seen.insert(t.key()))
};
if !unique(la) || !unique(lb) {
out.push(Change::SetDoc { prop: self.whole, value: Json::of(lb)? });
return Ok(());
}
let old: BTreeMap<T::Key, &T> = la.iter().map(|t| (t.key(), t)).collect();
let new: BTreeSet<T::Key> = lb.iter().map(T::key).collect();
for t in lb {
if old.get(&t.key()).is_none_or(|o| *o != t) {
out.push(Change::Put { entry: (self.entry)(t.key()), value: Json::of(t)? });
}
}
for t in la {
if !new.contains(&t.key()) {
out.push(Change::Remove { entry: (self.entry)(t.key()) });
}
}
// The order the puts and removes leave on `a` (survivors in place, new ones appended).
let kb: Vec<T::Key> = lb.iter().map(T::key).collect();
let left: Vec<T::Key> =
la.iter().map(T::key).filter(|k| new.contains(k)).chain(kb.iter().filter(|k| !old.contains_key(*k)).cloned()).collect();
if left != kb {
out.push(Change::SetDoc { prop: self.order, value: Json::of(&kb)? });
}
Ok(())
}
fn put(&self, doc: &mut Document, key: T::Key, value: &Json) -> Result<(), Error> {
let t: T = value.read("entry")?;
if t.key() != key {
return Err(Error::Invalid("an entry's id must match the entry it is put at".into()));
}
let list = (self.get_mut)(doc);
match list.iter_mut().find(|e| e.key() == key) {
Some(e) => *e = t,
None => list.push(t),
}
Ok(())
}
fn remove(&self, doc: &mut Document, key: &T::Key) {
(self.get_mut)(doc).retain(|e| e.key() != *key);
}
/// Entries in `keys` order first, the rest after in their current order.
fn reorder(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
let keys: Vec<T::Key> = value.read("order")?;
let list = (self.get_mut)(doc);
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
let mut at: BTreeMap<T::Key, Vec<usize>> = BTreeMap::new();
for (i, e) in slots.iter().enumerate() {
if let Some(e) = e {
at.entry(e.key()).or_default().push(i);
}
}
for k in keys {
let Some(idx) = at.get_mut(&k) else { continue };
if idx.is_empty() {
continue;
}
let i = idx.remove(0);
if let Some(e) = slots.get_mut(i).and_then(Option::take) {
list.push(e);
}
}
list.extend(slots.into_iter().flatten());
Ok(())
}
fn set_whole(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
*(self.get_mut)(doc) = value.read("list")?;
Ok(())
}
}
/// An image as it travels in a change.
#[derive(Serialize, Deserialize)]
struct ImageValue {
width: u32,
height: u32,
/// The encoded file (PNG, JPEG…), base64.
bytes: String,
}
fn image_json(img: &ImageData) -> Result<Json, Error> {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
fn image_of(value: &Json) -> Result<ImageData, Error> {
let v: ImageValue = value.read("image")?;
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
macro_rules! doc_scalars {
($($variant:ident => $field:ident),* $(,)?) => {
fn diff_scalars(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<(), Error> {
$(if a.$field != b.$field {
out.push(Change::SetDoc { prop: DocProp::$variant, value: Json::of(&b.$field)? });
})*
Ok(())
}
/// A scalar document property's value; `None` for list properties.
fn get_scalar(doc: &Document, prop: DocProp) -> Result<Option<Json>, Error> {
match prop {
$(DocProp::$variant => Json::of(&doc.$field).map(Some),)*
_ => Ok(None),
}
}
/// Write a scalar document property; false for list properties.
fn set_scalar(doc: &mut Document, prop: DocProp, value: &Json) -> Result<bool, Error> {
match prop {
$(DocProp::$variant => doc.$field = value.read(stringify!($field))?,)*
_ => return Ok(false),
}
Ok(true)
}
};
}
doc_scalars! {
Name => name,
CurrentPage => current_page,
DocumentColors => document_colors,
ColorProfile => color_profile,
ImagePresets => image_presets,
AnnotationCategories => annotation_categories,
Libraries => libraries,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
fn diff_map<K: Ord + Clone, V>(
a: &BTreeMap<K, V>,
b: &BTreeMap<K, V>,
same: impl Fn(&V, &V) -> bool,
entry: impl Fn(&K) -> Entry,
json: impl Fn(&V) -> Result<Json, Error>,
out: &mut Vec<Change>,
) -> Result<(), Error> {
for (k, v) in b {
if a.get(k).is_none_or(|o| !same(o, v)) {
out.push(Change::Put { entry: entry(k), value: json(v)? });
}
}
for k in a.keys() {
if !b.contains_key(k) {
out.push(Change::Remove { entry: entry(k) });
}
}
Ok(())
}
/// The document-level changes that turn `a` into `b`.
pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<(), Error> {
diff_scalars(a, b, out)?;
STYLES.diff(a, b, out)?;
COMMENTS.diff(a, b, out)?;
COLLECTIONS.diff(a, b, out)?;
VARIABLES.diff(a, b, out)?;
diff_map(&a.dev_snapshots, &b.dev_snapshots, |x, y| Arc::ptr_eq(x, y) || x == y, |k| Entry::DevSnapshot(*k), Json::of, out)?;
diff_map(
&a.images,
&b.images,
|x, y| x.width == y.width && x.height == y.height && (Arc::ptr_eq(&x.bytes, &y.bytes) || x.bytes == y.bytes),
|k| Entry::Image(k.clone()),
image_json,
out,
)
}
/// Apply a document-level change. Returns whether it can change which pages are listed (the
/// library store page is never listed).
pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error> {
match change {
Change::SetDoc { prop, value } => {
if set_scalar(doc, *prop, value)? {
return Ok(*prop == DocProp::Libraries);
}
match prop {
DocProp::Styles => STYLES.set_whole(doc, value),
DocProp::StyleOrder => STYLES.reorder(doc, value),
DocProp::Comments => COMMENTS.set_whole(doc, value),
DocProp::CommentOrder => COMMENTS.reorder(doc, value),
DocProp::Collections => COLLECTIONS.set_whole(doc, value),
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value),
DocProp::Variables => VARIABLES.set_whole(doc, value),
DocProp::VariableOrder => VARIABLES.reorder(doc, value),
_ => Err(Error::Invalid(format!("{prop:?} is not a list"))),
}?;
}
Change::Put { entry, value } => match entry {
Entry::Style(k) => STYLES.put(doc, k.clone(), value)?,
Entry::Comment(k) => COMMENTS.put(doc, *k, value)?,
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value)?,
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value)?,
Entry::DevSnapshot(k) => {
let snap: DevSnapshot = value.read("dev snapshot")?;
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
let img = image_of(value)?;
doc.images.insert(k.clone(), img);
}
},
Change::Remove { entry } => match entry {
Entry::Style(k) => STYLES.remove(doc, k),
Entry::Comment(k) => COMMENTS.remove(doc, k),
Entry::Collection(k) => COLLECTIONS.remove(doc, k),
Entry::Variable(k) => VARIABLES.remove(doc, k),
Entry::DevSnapshot(k) => {
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
doc.images.remove(k);
}
},
_ => return Err(Error::Invalid("not a document-level change".into())),
}
Ok(false)
}
/// The current value of what `change` writes, as a change (for a rejected batch's fixes): the
/// property or entry as it is now, or the entry's removal. List properties revert whole.
pub(crate) fn current(doc: &Document, change: &Change) -> Result<Option<Change>, Error> {
let entry = match change {
Change::SetDoc { prop, .. } => {
if let Some(value) = get_scalar(doc, *prop)? {
return Ok(Some(Change::SetDoc { prop: *prop, value }));
}
let (prop, value) = match prop {
DocProp::Styles | DocProp::StyleOrder => (DocProp::Styles, Json::of(&doc.styles)?),
DocProp::Comments | DocProp::CommentOrder => (DocProp::Comments, Json::of(&doc.comments)?),
DocProp::Collections | DocProp::CollectionOrder => (DocProp::Collections, Json::of(&doc.variables.collections)?),
_ => (DocProp::Variables, Json::of(&doc.variables.variables)?),
};
return Ok(Some(Change::SetDoc { prop, value }));
}
Change::Put { entry, .. } | Change::Remove { entry } => entry,
_ => return Ok(None),
};
let value = match entry {
Entry::Style(k) => doc.styles.iter().find(|s| s.id == *k).map(Json::of),
Entry::Comment(k) => doc.comments.iter().find(|c| c.id == *k).map(Json::of),
Entry::Collection(k) => doc.variables.collections.iter().find(|c| c.id == *k).map(Json::of),
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
Entry::Image(k) => doc.images.get(k).map(image_json),
};
Ok(Some(match value {
Some(v) => Change::Put { entry: entry.clone(), value: v? },
None => Change::Remove { entry: entry.clone() },
}))
}

353
crates/collab/src/frac.rs Normal file
View File

@ -0,0 +1,353 @@
//! Fractional indexing: sibling order as sortable string keys.
//!
//! A parent's children are ordered by `(position key, id)`. A key can always be made between two
//! others, so inserting or moving one layer writes one key and never renumbers its siblings, and two
//! people inserting into the same frame at the same time don't conflict (equal keys fall back to the
//! id order, which every replica agrees on).
//!
//! A key is an *integer part* followed by an optional *fraction*, all in base-62 digits
//! (`0-9A-Za-z`, which sort the same as bytes). The integer part's first character gives its
//! length: `a`–`z` are 1 to 26 digits counting up, `A`–`Z` 26 down to 1 digit counting down below
//! zero. The first key is `a0`; appending counts up (`a1`, `a2` … `az`, `b00` …), so keys made by
//! appending grow logarithmically; inserting between two keys extends the fraction (between `a1`
//! and `a2` sits `a1V`). Fractions never end in `0`, so there is always room between two keys.
//! Every function is deterministic: the same inputs give the same key on every machine.
use std::fmt;
/// The digits, in sort order.
const DIGITS: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const BASE: usize = 62;
/// Keys longer than this are refused (a replica re-spaces a parent's children before its keys get
/// this long; see [`KEY_RESPACE_LEN`]).
pub const MAX_KEY_LEN: usize = 128;
/// A generated key longer than this makes the generator re-space all of a parent's children with
/// short keys instead.
pub const KEY_RESPACE_LEN: usize = 48;
/// The key a first child gets.
pub const FIRST_KEY: &str = "a0";
/// Why a key is invalid or no key fits.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeyError {
/// Not a well-formed key (the string is included, shortened).
Invalid(String),
/// The bounds are not in increasing order.
Order,
/// No key fits (below the smallest representable key, or above the largest).
Exhausted,
}
impl fmt::Display for KeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyError::Invalid(k) => write!(f, "invalid position key {k:?}"),
KeyError::Order => write!(f, "position keys out of order"),
KeyError::Exhausted => write!(f, "no position key fits"),
}
}
}
impl std::error::Error for KeyError {}
fn digit_value(c: u8) -> Option<usize> {
match c {
b'0'..=b'9' => Some(usize::from(c - b'0')),
b'A'..=b'Z' => Some(usize::from(c - b'A') + 10),
b'a'..=b'z' => Some(usize::from(c - b'a') + 36),
_ => None,
}
}
fn digit(v: usize) -> u8 {
DIGITS.get(v).copied().unwrap_or(b'0')
}
/// Length of the integer part (head included) for head character `h`.
fn int_len(h: u8) -> Option<usize> {
match h {
b'a'..=b'z' => Some(usize::from(h - b'a') + 2),
b'A'..=b'Z' => Some(usize::from(b'Z' - h) + 2),
_ => None,
}
}
/// The smallest integer part ("A" followed by 26 zeros): keys can't go below it.
fn is_smallest_int(i: &[u8]) -> bool {
i.first() == Some(&b'A') && i.len() == 27 && i.iter().skip(1).all(|c| *c == b'0')
}
fn shorten(k: &str) -> String {
k.chars().take(40).collect()
}
/// Split a valid key into its integer part and fraction.
fn split(key: &str) -> Result<(&[u8], &[u8]), KeyError> {
let b = key.as_bytes();
let n = b.first().and_then(|h| int_len(*h)).ok_or_else(|| KeyError::Invalid(shorten(key)))?;
if b.len() < n {
return Err(KeyError::Invalid(shorten(key)));
}
Ok(b.split_at(n))
}
/// Check that `key` is a well-formed position key.
pub fn validate(key: &str) -> Result<(), KeyError> {
let bad = || KeyError::Invalid(shorten(key));
if key.len() > MAX_KEY_LEN || !key.is_ascii() {
return Err(bad());
}
let (int, frac) = split(key)?;
if int.iter().skip(1).chain(frac).any(|c| digit_value(*c).is_none()) || frac.last() == Some(&b'0') {
return Err(bad());
}
if is_smallest_int(int) && frac.is_empty() {
return Err(bad());
}
Ok(())
}
fn to_string(v: Vec<u8>) -> String {
// Keys are built from ASCII digits only.
String::from_utf8(v).unwrap_or_default()
}
/// A fraction strictly between fractions `a` and `b` (`None`: 1), neither ending in `0`, `a < b`.
fn midpoint(a: &[u8], b: Option<&[u8]>) -> Result<Vec<u8>, KeyError> {
let mut out = Vec::new();
let (mut a, mut b) = (a, b);
// Each round consumes at least one digit of `a` or `b`, so this ends; the cap guards hostile input.
for _ in 0..(MAX_KEY_LEN * 2 + 4) {
if let Some(bb) = b {
// Shared prefix (missing digits of `a` read as 0).
let mut n = 0;
while n < bb.len() && a.get(n).copied().unwrap_or(b'0') == bb.get(n).copied().unwrap_or(0) {
n += 1;
}
if n > 0 {
out.extend_from_slice(bb.get(..n).unwrap_or_default());
a = a.get(n..).unwrap_or_default();
b = Some(bb.get(n..).unwrap_or_default());
continue;
}
}
let da = match a.first() {
Some(c) => digit_value(*c).ok_or(KeyError::Order)?,
None => 0,
};
let db = match b {
Some(bb) => bb.first().and_then(|c| digit_value(*c)).ok_or(KeyError::Order)?,
None => BASE,
};
if db <= da {
return Err(KeyError::Order);
}
if db - da > 1 {
out.push(digit((da + db).div_ceil(2)));
return Ok(out);
}
// Consecutive digits: `b`'s first digit alone is between when `b` goes on; otherwise keep
// `a`'s digit and find a fraction above the rest of `a`.
if let Some(bb) = b
&& bb.len() > 1
{
out.push(bb.first().copied().unwrap_or(b'0'));
return Ok(out);
}
out.push(digit(da));
a = a.get(1..).unwrap_or_default();
b = None;
}
Err(KeyError::Exhausted)
}
/// The integer part after `int`, or `None` past the largest.
fn increment_int(int: &[u8]) -> Option<Vec<u8>> {
let (&head, digits) = int.split_first()?;
let mut d = digits.to_vec();
let mut carry = true;
for c in d.iter_mut().rev() {
let v = digit_value(*c)? + 1;
if v == BASE {
*c = b'0';
} else {
*c = digit(v);
carry = false;
break;
}
}
if !carry {
let mut out = vec![head];
out.extend(d);
return Some(out);
}
match head {
b'Z' => Some(b"a0".to_vec()),
b'z' => None,
_ => {
let h = head + 1;
if h > b'a' {
d.push(b'0');
} else {
d.pop();
}
let mut out = vec![h];
out.extend(d);
Some(out)
}
}
}
/// The integer part before `int`, or `None` below the smallest.
fn decrement_int(int: &[u8]) -> Option<Vec<u8>> {
let (&head, digits) = int.split_first()?;
let mut d = digits.to_vec();
let mut borrow = true;
for c in d.iter_mut().rev() {
let v = digit_value(*c)?;
if v == 0 {
*c = digit(BASE - 1);
} else {
*c = digit(v - 1);
borrow = false;
break;
}
}
if !borrow {
let mut out = vec![head];
out.extend(d);
return Some(out);
}
match head {
b'a' => Some(vec![b'Z', digit(BASE - 1)]),
b'A' => None,
_ => {
let h = head - 1;
if h < b'Z' {
d.push(digit(BASE - 1));
} else {
d.pop();
}
let mut out = vec![h];
out.extend(d);
Some(out)
}
}
}
/// A key strictly between `a` and `b` (`None`: unbounded on that side). Both must be valid and
/// `a < b`.
pub fn between(a: Option<&str>, b: Option<&str>) -> Result<String, KeyError> {
for k in [a, b].into_iter().flatten() {
validate(k)?;
}
if let (Some(a), Some(b)) = (a, b)
&& a >= b
{
return Err(KeyError::Order);
}
match (a, b) {
(None, None) => Ok(FIRST_KEY.to_string()),
(None, Some(b)) => {
let (ib, fb) = split(b)?;
if is_smallest_int(ib) {
let mut out = ib.to_vec();
out.extend(midpoint(b"", Some(fb))?);
return Ok(to_string(out));
}
if !fb.is_empty() {
return Ok(to_string(ib.to_vec()));
}
let d = decrement_int(ib).ok_or(KeyError::Exhausted)?;
if is_smallest_int(&d) {
// The smallest integer alone isn't a key: put a fraction after it.
let mut out = d;
out.push(digit(BASE - 1));
return Ok(to_string(out));
}
Ok(to_string(d))
}
(Some(a), None) => {
let (ia, fa) = split(a)?;
match increment_int(ia) {
Some(i) => Ok(to_string(i)),
None => {
let mut out = ia.to_vec();
out.extend(midpoint(fa, None)?);
Ok(to_string(out))
}
}
}
(Some(a), Some(b)) => {
let (ia, fa) = split(a)?;
let (ib, fb) = split(b)?;
if ia == ib {
let mut out = ia.to_vec();
out.extend(midpoint(fa, Some(fb))?);
return Ok(to_string(out));
}
if let Some(i) = increment_int(ia)
&& i.as_slice() < b.as_bytes()
{
return Ok(to_string(i));
}
let mut out = ia.to_vec();
out.extend(midpoint(fa, None)?);
Ok(to_string(out))
}
}
}
/// `n` keys in increasing order, all strictly between `a` and `b` (`None`: unbounded). Bisects, so
/// keys stay short however many there are.
pub fn n_between(a: Option<&str>, b: Option<&str>, n: usize) -> Result<Vec<String>, KeyError> {
let mut out = Vec::with_capacity(n.min(1 << 16));
fill(a, b, n, &mut out, 0)?;
Ok(out)
}
fn fill(a: Option<&str>, b: Option<&str>, n: usize, out: &mut Vec<String>, depth: usize) -> Result<(), KeyError> {
if n == 0 {
return Ok(());
}
if depth > 64 {
return Err(KeyError::Exhausted);
}
match (a, b) {
// Unbounded above: count up (integer keys stay short).
(_, None) => {
let mut c = between(a, None)?;
for _ in 1..n {
let next = between(Some(&c), None)?;
out.push(std::mem::replace(&mut c, next));
}
out.push(c);
Ok(())
}
// Unbounded below: count down, then reverse.
(None, Some(_)) => {
let start = out.len();
let mut c = between(None, b)?;
for _ in 1..n {
let next = between(None, Some(&c))?;
out.push(std::mem::replace(&mut c, next));
}
out.push(c);
if let Some(tail) = out.get_mut(start..) {
tail.reverse();
}
Ok(())
}
(Some(_), Some(_)) => {
let mid = n / 2;
let c = between(a, b)?;
fill(a, Some(&c), mid, out, depth + 1)?;
let rest = n - mid - 1;
out.push(c.clone());
fill(Some(&c), b, rest, out, depth + 1)
}
}
}

View File

@ -0,0 +1,104 @@
//! Fractional position keys.
use crate::frac::{FIRST_KEY, KeyError, between, n_between, validate};
use crate::sim::Rng;
#[test]
fn first_keys_count_up_and_down() {
assert_eq!(between(None, None).unwrap(), FIRST_KEY);
assert_eq!(between(Some("a0"), None).unwrap(), "a1");
assert_eq!(between(Some("az"), None).unwrap(), "b00");
assert_eq!(between(None, Some("a0")).unwrap(), "Zz");
assert_eq!(between(None, Some("a1V")).unwrap(), "a1");
assert_eq!(between(Some("a0"), Some("a1")).unwrap(), "a0V");
assert_eq!(between(Some("a0"), Some("a2")).unwrap(), "a1");
assert_eq!(between(Some("a0V"), Some("a1")).unwrap(), "a0l");
assert_eq!(between(Some("a0"), Some("a0V")).unwrap(), "a0G");
assert_eq!(between(Some("Zz"), Some("a0")).unwrap(), "ZzV");
}
#[test]
fn bad_keys_and_bounds_are_refused() {
for bad in ["", "a", "a00", "a0 ", "b1", "!0", "a0é", "A00000000000000000000000000"] {
assert!(validate(bad).is_err(), "{bad:?}");
assert!(between(Some(bad), None).is_err());
}
assert!(validate(&format!("a0{}", "1".repeat(200))).is_err(), "too long");
for good in ["a0", "a0V", "Zz", "b00", "zzzzzzzzzzzzzzzzzzzzzzzzzzz", "A000000000000000000000000001"] {
assert_eq!(validate(good), Ok(()), "{good:?}");
}
assert_eq!(between(Some("a1"), Some("a1")), Err(KeyError::Order));
assert_eq!(between(Some("a2"), Some("a1")), Err(KeyError::Order));
// Past the largest integer, the fraction grows instead of failing.
let top = "zzzzzzzzzzzzzzzzzzzzzzzzzzz";
let after = between(Some(top), None).unwrap();
assert!(after.as_str() > top);
}
#[test]
fn many_keys_are_ordered_and_short() {
let keys = n_between(None, None, 1000).unwrap();
assert!(keys.windows(2).all(|w| w[0] < w[1]));
assert!(keys.iter().all(|k| k.len() <= 3), "counting up keeps keys short");
let inside = n_between(Some("a0"), Some("a1"), 1000).unwrap();
assert_eq!(inside.len(), 1000);
assert!(inside.windows(2).all(|w| w[0] < w[1]));
assert!(inside.first().unwrap().as_str() > "a0" && inside.last().unwrap().as_str() < "a1");
assert!(inside.iter().all(|k| k.len() <= 8), "bisection keeps keys short");
let below = n_between(None, Some("a0"), 50).unwrap();
assert!(below.windows(2).all(|w| w[0] < w[1]) && below.last().unwrap().as_str() < "a0");
assert!(n_between(Some("a0"), Some("a1"), 0).unwrap().is_empty());
}
#[test]
fn random_inserts_stay_ordered_valid_and_deterministic() {
for seed in 0..20 {
let mut rng = Rng::new(seed);
let mut keys: Vec<String> = vec![between(None, None).unwrap()];
for _ in 0..300 {
let i = rng.below(keys.len() + 1);
let lo = i.checked_sub(1).map(|j| keys[j].clone());
let hi = keys.get(i).cloned();
let k = between(lo.as_deref(), hi.as_deref()).unwrap();
assert_eq!(validate(&k), Ok(()));
assert_eq!(between(lo.as_deref(), hi.as_deref()).unwrap(), k, "deterministic");
assert!(lo.as_deref().is_none_or(|l| l < k.as_str()) && hi.as_deref().is_none_or(|h| k.as_str() < h));
keys.insert(i, k);
}
assert!(keys.windows(2).all(|w| w[0] < w[1]));
}
}
#[test]
fn appending_and_prepending_forever_never_fails() {
let mut k = FIRST_KEY.to_string();
for _ in 0..5000 {
k = between(Some(&k), None).unwrap();
}
assert!(k.len() <= 4);
let mut k = FIRST_KEY.to_string();
for _ in 0..5000 {
k = between(None, Some(&k)).unwrap();
}
assert!(k.len() <= 4);
// Squeezing next to one key: the fraction grows by about one digit per six inserts.
let mut hi = "a1".to_string();
for _ in 0..60 {
hi = between(Some("a0"), Some(&hi)).unwrap();
}
assert!(hi.len() <= 16, "{hi}");
}
#[test]
fn junk_keys_never_panic() {
let mut rng = Rng::new(7);
for _ in 0..5000 {
let a: String = (0..rng.below(12)).map(|_| char::from(rng.below(128) as u8)).collect();
let b: String = (0..rng.below(12)).map(|_| char::from(32 + rng.below(95) as u8)).collect();
let _ = validate(&a);
let _ = between(Some(&a), Some(&b));
let _ = between(None, Some(&b));
let _ = between(Some(&a), None);
let _ = n_between(Some(&a), Some(&b), 5);
}
}

101
crates/collab/src/lib.rs Normal file
View File

@ -0,0 +1,101 @@
//! Real-time collaboration core for FigCraft: the synced object/property model over
//! [`Document`], change sets, the session server's ordering and validation, the client's
//! optimistic state, and the wire messages. Pure library: no networking, no UI (the WebSocket
//! transport comes on top). The design is `docs/collab-protocol.md`.
//!
//! - A [`Replica`] is one copy of the synced state: the document plus each node's fractional
//! position key ([`frac`]).
//! - [`diff`] turns an edit (the document before and after a command) into a batch of
//! [`Change`]s at property granularity; [`Replica::apply`] applies a batch.
//! - A [`Server`] orders batches (one sequence number each), validates them and rebroadcasts them;
//! a [`Client`] applies its own edits at once and reconciles when the server confirms, rejects or
//! orders someone else's batch first. Conflicting writes to one property resolve last-writer-wins
//! in server order, so every replica converges to the server's state.
//! - [`sim`] runs a server and several clients over in-memory links with random delays (tests).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod change;
pub mod client;
mod diff;
mod docprops;
pub mod frac;
pub mod message;
pub mod replica;
pub mod server;
pub mod sim;
use std::collections::BTreeSet;
pub use change::{Change, DocProp, Entry, Json, Prop};
pub use client::Client;
pub use diff::{Diff, diff};
pub use figcraft_doc::{Document, NodeId};
pub use message::{ClientMsg, Hello, Peer, Presence, ServerMsg};
pub use replica::{Applied, Mode, Replica};
pub use server::{Outgoing, Server};
/// What can go wrong syncing.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Error {
/// A change names a node that doesn't exist (or no longer does).
#[error("no such node {0}")]
Unknown(NodeId),
/// A change or message is not valid.
#[error("{0}")]
Invalid(String),
#[error("can't encode: {0}")]
Encode(String),
#[error("can't decode: {0}")]
Decode(String),
/// The other side broke the protocol (a message out of order or out of place).
#[error("protocol error: {0}")]
Protocol(String),
}
/// Size limits the server enforces (and decoders use).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Largest message, binary or JSON.
pub max_message_bytes: usize,
/// Most changes in one batch.
pub max_changes: usize,
/// Most nodes in a document.
pub max_nodes: usize,
/// Most people in a session.
pub max_peers: usize,
/// Longest display name or colour string (chars).
pub max_name: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { max_message_bytes: 64 << 20, max_changes: 500_000, max_nodes: 5_000_000, max_peers: 256, max_name: 64 }
}
}
/// Brings derived data up to date after changes (auto layout, instances following their mains,
/// variable values…). The engine implements it with its normalize pass; a client runs it over its
/// own batch when the server ordered someone else's batch first, and sends what it changes as a
/// follow-up batch (see [`Client::receive`]).
pub trait Recompute {
/// `doc` is `before` with the nodes in `touched` changed; update what depends on them.
fn recompute(&self, doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>);
}
/// Leaves derived data as the authors computed it.
pub struct NoRecompute;
impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod codec_tests;
#[cfg(test)]
mod diff_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod sim_tests;

View File

@ -0,0 +1,152 @@
//! Messages between clients and the session server, and their two encodings: a compact binary form
//! (a version byte, then postcard) and a JSON form for debugging and agents.
use figcraft_doc::NodeId;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::Error;
use crate::change::Change;
/// The protocol version a client announces in [`Hello`].
pub const PROTOCOL: u32 = 1;
/// The first byte of every binary message.
pub const WIRE_VERSION: u8 = 1;
/// Join a file's session.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hello {
pub protocol: u32,
/// The file's session id (from the invite).
pub file: String,
/// The session's secret (from the invite; empty when the session has none).
pub token: String,
/// The name shown on this person's cursor.
pub name: String,
/// Their colour (`#RRGGBB`).
pub color: String,
pub client_version: String,
}
/// Someone in the session.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Peer {
pub client_id: u32,
pub name: String,
pub color: String,
}
/// Where someone is and what they look at; ephemeral (never stored), latest wins.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Presence {
/// The page the pointer is on and its position in page coordinates.
pub cursor: Option<(NodeId, f64, f64)>,
pub selection: Option<Vec<NodeId>>,
/// The page shown and the visible rectangle `(x, y, width, height)` in page coordinates.
pub viewport: Option<(NodeId, f64, f64, f64, f64)>,
/// The text layer being typed in.
pub editing_text: Option<NodeId>,
}
/// Client → server.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ClientMsg {
/// Join (the first message on a connection).
Hello(Hello),
/// One user action's changes, made on top of server state `base_seq`.
Changes {
base_seq: u64,
batch_id: u64,
changes: Vec<Change>,
},
Presence(Presence),
/// The highest sequence number applied.
Ack {
seq: u64,
},
Ping,
}
/// Server → client.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ServerMsg {
/// Joined: this connection's client id, the current sequence number, the document as a
/// snapshot ([`Replica::snapshot`](crate::Replica::snapshot)) and who else is here.
Welcome {
client_id: u32,
seq: u64,
snapshot: Vec<Change>,
peers: Vec<Peer>,
},
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
Changes {
seq: u64,
author: u32,
batch_id: u64,
changes: Vec<Change>,
},
/// A batch was refused (nothing of it applies): why, and the current values of what it
/// touched.
Reject {
batch_id: u64,
reason: String,
fixes: Vec<Change>,
},
/// Someone's presence.
Presence {
client_id: u32,
presence: Presence,
},
PeerJoined(Peer),
PeerLeft {
client_id: u32,
},
/// The connection is refused (bad protocol version or token, session full, a message out of
/// place).
Refused {
reason: String,
},
Pong,
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(body.len() + 1);
out.push(WIRE_VERSION);
out.extend(body);
Ok(out)
}
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
/// about the message's size, and refuses trailing bytes).
pub fn decode<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T, Error> {
if bytes.len() > max_bytes {
return Err(Error::Decode(format!("message of {} bytes is over the {max_bytes}-byte limit", bytes.len())));
}
let Some((&version, body)) = bytes.split_first() else { return Err(Error::Decode("empty message".into())) };
if version != WIRE_VERSION {
return Err(Error::Decode(format!("wire version {version}, expected {WIRE_VERSION}")));
}
let (msg, rest) = postcard::take_from_bytes::<T>(body).map_err(|e| Error::Decode(e.to_string()))?;
if !rest.is_empty() {
return Err(Error::Decode(format!("{} bytes after the message", rest.len())));
}
Ok(msg)
}
/// The JSON form of `msg` (`{"<type>": {…}}`).
pub fn encode_json<T: Serialize>(msg: &T) -> Result<String, Error> {
serde_json::to_string(msg).map_err(|e| Error::Encode(e.to_string()))
}
/// Read a JSON message of at most `max_bytes`.
pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<T, Error> {
if text.len() > max_bytes {
return Err(Error::Decode(format!("message of {} bytes is over the {max_bytes}-byte limit", text.len())));
}
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
}

View File

@ -0,0 +1,314 @@
//! A replica: one copy of the synced document state (the document plus every node's fractional
//! position), and applying changes to it.
use std::collections::BTreeSet;
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::frac::{self, FIRST_KEY};
use crate::{Error, docprops};
/// Fractional position keys by node.
pub type Positions = imbl::OrdMap<NodeId, String>;
/// Largest coordinate, size or transform coefficient a synced node may have.
pub const MAX_COORD: f64 = 1e9;
/// One copy of the synced state: the document and the position key of every node. Children are
/// always ordered by `(key, id)` (pages too, as children of the document), so applying the same
/// changes in the same order gives the same document everywhere. Cloning is cheap (persistent maps).
#[derive(Clone, Debug, PartialEq)]
pub struct Replica {
doc: Document,
positions: Positions,
}
/// How strictly [`Replica::apply`] treats a change it can't apply.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// Stop at the first invalid change and also check the result (types, numbers, tree rules).
/// The replica is left partly changed: apply to a clone and keep it only on success.
Strict,
/// Skip changes that can't be applied (a client replaying its own unconfirmed edits on top of
/// newer server state).
Lenient,
}
/// What [`Replica::apply`] did.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Applied {
/// Nodes added, edited or removed (as the document's dirty set records them).
pub touched: BTreeSet<NodeId>,
/// Nodes deleted (subtrees included).
pub deleted: Vec<NodeId>,
/// Changes skipped in [`Mode::Lenient`].
pub skipped: usize,
}
impl Replica {
/// Start syncing `doc`: every node gets a position key in its current order (pages by the
/// page list). The published library isn't synced and is dropped from the replica.
pub fn new(mut doc: Document) -> Replica {
let mut positions = Positions::new();
let mut assign = |ids: &[NodeId]| {
for (id, k) in ids.iter().zip(frac::n_between(None, None, ids.len()).unwrap_or_default()) {
positions.insert(*id, k);
}
};
assign(&doc.pages);
for n in doc.nodes() {
assign(&n.children);
}
// Pages that aren't listed (the library store) still need a key.
let roots: Vec<NodeId> = doc.nodes().filter(|n| n.parent.is_none()).map(|n| n.id).filter(|id| !positions.contains_key(id)).collect();
for id in roots {
positions.insert(id, FIRST_KEY.to_string());
}
doc.published = None;
doc.take_dirty();
Replica { doc, positions }
}
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result<Replica, Error> {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
r.apply(changes, Mode::Strict)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
Ok(r)
}
/// The whole state as changes that build it from nothing (pages and their trees in order,
/// then the document-level data).
pub fn snapshot(&self) -> Result<Vec<Change>, Error> {
let mut out = Vec::with_capacity(self.doc.node_count() + 8);
let mut roots = self.doc.pages.clone();
roots.extend(self.doc.nodes().filter(|n| n.parent.is_none() && !self.doc.pages.contains(&n.id)).map(|n| n.id));
for r in roots {
for id in self.doc.subtree(r) {
if let Some(n) = self.doc.node(id) {
out.push(create_change(n, self.key(id))?);
}
}
}
docprops::diff(&Document::without_pages(""), &self.doc, &mut out)?;
Ok(out)
}
/// The synced document.
pub fn document(&self) -> &Document {
&self.doc
}
/// Mutable access to the document for allocation settings (client id, id counter). Edits made
/// here bypass syncing: make them with [`diff`](crate::diff) and [`apply`](Self::apply).
pub fn document_mut(&mut self) -> &mut Document {
&mut self.doc
}
/// Every node's position key.
pub fn positions(&self) -> &Positions {
&self.positions
}
/// Node `id`'s position key ("" when it has none, which sorts first).
pub fn key(&self, id: NodeId) -> &str {
self.positions.get(&id).map_or("", String::as_str)
}
pub(crate) fn from_parts(doc: Document, positions: Positions) -> Replica {
Replica { doc, positions }
}
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
/// how many of them sort before it.
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
}
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
/// any order is accepted. Pages are re-listed by their keys at the end.
pub fn apply(&mut self, changes: &[Change], mode: Mode) -> Result<Applied, Error> {
self.doc.take_dirty();
let mut out = Applied::default();
let (mut relist, mut relist_all) = (BTreeSet::new(), false);
for c in changes {
match self.apply_one(c, &mut out) {
Ok(Relist::No) => {}
Ok(Relist::Page(p)) => {
relist.insert(p);
}
Ok(Relist::All) => relist_all = true,
Err(e) if mode == Mode::Strict => return Err(e),
Err(_) => out.skipped += 1,
}
}
if relist_all || !relist.is_empty() {
self.relist_pages(relist, relist_all);
}
out.touched = self.doc.take_dirty();
if mode == Mode::Strict {
self.check(&out.touched)?;
}
Ok(out)
}
/// Apply one change. Returns what of the page list needs rebuilding.
fn apply_one(&mut self, c: &Change, out: &mut Applied) -> Result<Relist, Error> {
match c {
Change::Create { id, kind, parent, position, props } => {
frac::validate(position).map_err(|e| Error::Invalid(e.to_string()))?;
if self.doc.contains(*id) {
return Err(Error::Invalid(format!("node {id} already exists")));
}
let kind: NodeKind = kind.read("kind")?;
let mut node = Node::new(*id, kind, "", 0.0, 0.0);
for (p, v) in props {
if *p == Prop::Kind {
return Err(Error::Invalid("a created node's kind is its own field".into()));
}
p.set(&mut node, v)?;
}
let is_page = node.is_page();
match (is_page, parent) {
(true, None) => {
self.doc.insert_page(node).map_err(doc_err)?;
}
(false, Some(p)) => {
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
}
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
}
self.positions.insert(*id, position.clone());
Ok(if is_page { Relist::Page(*id) } else { Relist::No })
}
Change::Set { id, prop: Prop::Parent, value } => {
let parent: Option<NodeId> = value.read("parent")?;
let node = self.doc.get(*id).map_err(doc_err)?;
match (node.is_page(), parent) {
(true, None) => Ok(Relist::No),
(false, Some(p)) => {
let at = self.slot(&self.doc.get(p).map_err(doc_err)?.children, *id, self.key(*id));
self.doc.place(*id, p, at).map_err(doc_err)?;
Ok(Relist::No)
}
_ => Err(Error::Invalid("pages, and only pages, have no parent".into())),
}
}
Change::Set { id, prop: Prop::Position, value } => {
let key: String = value.read("position")?;
frac::validate(&key).map_err(|e| Error::Invalid(e.to_string()))?;
let node = self.doc.get(*id).map_err(doc_err)?;
let parent = node.parent;
self.positions.insert(*id, key.clone());
match parent {
None => Ok(Relist::Page(*id)),
Some(p) => {
let at = self.slot(&self.doc.get(p).map_err(doc_err)?.children, *id, &key);
self.doc.reorder(*id, at).map_err(doc_err)?;
Ok(Relist::No)
}
}
}
Change::Set { id, prop: Prop::Kind, value } => {
let kind: NodeKind = value.read("kind")?;
let node = self.doc.get_mut(*id).map_err(doc_err)?;
if node.is_page() != matches!(kind, NodeKind::Page { .. }) {
return Err(Error::Invalid("a node can't become or stop being a page".into()));
}
node.kind = kind;
Ok(Relist::No)
}
Change::Set { id, prop, value } => {
let node = self.doc.get_mut(*id).map_err(doc_err)?;
prop.set(node, value)?;
Ok(Relist::No)
}
Change::Delete { id } => {
let Some(n) = self.doc.node(*id) else { return Ok(Relist::No) };
let page = n.is_page();
let gone = self.doc.subtree(*id);
self.doc.remove(*id).map_err(doc_err)?;
for g in &gone {
self.positions.remove(g);
}
out.deleted.extend(gone);
Ok(if page { Relist::Page(*id) } else { Relist::No })
}
Change::SetDoc { .. } | Change::Put { .. } | Change::Remove { .. } => {
Ok(if docprops::apply(&mut self.doc, c)? { Relist::All } else { Relist::No })
}
}
}
/// List the pages (all but the library store) ordered by `(key, id)`: the listed ones and
/// `extra`, or every page node when `all` (the library store may have changed).
fn relist_pages(&mut self, extra: BTreeSet<NodeId>, all: bool) {
let store = self.doc.libraries.store;
let mut ids: BTreeSet<NodeId> = extra;
ids.extend(self.doc.pages.iter().copied());
if all {
ids.extend(self.doc.nodes().filter(|n| n.parent.is_none()).map(|n| n.id));
}
let mut pages: Vec<NodeId> =
ids.into_iter().filter(|p| Some(*p) != store && self.doc.node(*p).is_some_and(|n| n.is_page() && n.parent.is_none())).collect();
pages.sort_by(|a, b| (self.key(*a), *a).cmp(&(self.key(*b), *b)));
self.doc.pages = pages;
}
/// The checks a server makes on what a batch touched.
fn check(&self, touched: &BTreeSet<NodeId>) -> Result<(), Error> {
if self.doc.pages.is_empty() {
return Err(Error::Invalid("a file needs at least one page".into()));
}
let finite = |v: f64| v.is_finite() && v.abs() <= MAX_COORD;
for id in touched {
let Some(n) = self.doc.node(*id) else { continue };
if !n.children.is_empty() && !n.kind.is_container() {
return Err(Error::Invalid(format!("{id} can't contain layers")));
}
if !(finite(n.width) && finite(n.height) && n.width >= 0.0 && n.height >= 0.0) {
return Err(Error::Invalid(format!("{id} has an invalid size")));
}
if !n.transform.as_coeffs().into_iter().all(finite) {
return Err(Error::Invalid(format!("{id} has an invalid transform")));
}
if !(n.opacity.is_finite() && (0.0..=1.0).contains(&n.opacity)) {
return Err(Error::Invalid(format!("{id} has an invalid opacity")));
}
}
Ok(())
}
}
/// The change that creates node `n` as it is, at position `key`: its kind, place and the
/// properties that differ from a new node of its kind.
pub(crate) fn create_change(n: &Node, key: &str) -> Result<Change, Error> {
let blank = Node::new(n.id, n.kind.clone(), "", 0.0, 0.0);
let mut props = Vec::new();
for p in Prop::FIELDS {
if *p != Prop::Kind && p.differs(&blank, n) {
props.push((*p, p.get(n)?));
}
}
Ok(Change::Create { id: n.id, kind: Json::of(&n.kind)?, parent: n.parent, position: key.to_string(), props })
}
/// What a change needs of the page list.
enum Relist {
No,
/// This page was added, moved or removed.
Page(NodeId),
/// The library store may have changed: look at every root.
All,
}
fn doc_err(e: figcraft_doc::DocError) -> Error {
match e {
figcraft_doc::DocError::NoNode(id) => Error::Unknown(id),
other => Error::Invalid(other.to_string()),
}
}

246
crates/collab/src/server.rs Normal file
View File

@ -0,0 +1,246 @@
//! The session server for one file: the single authority that orders, validates and rebroadcasts
//! batches. A pure state machine: the transport feeds it messages and delivers what it returns.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeId};
use crate::change::{Change, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops};
/// A message for the transport to deliver.
#[derive(Clone, Debug, PartialEq)]
pub enum Outgoing {
/// To one client.
To(u32, ServerMsg),
/// To every client.
All(ServerMsg),
/// To every client but one.
Others(u32, ServerMsg),
}
/// A session server for one file.
#[derive(Clone, Debug)]
pub struct Server {
state: Replica,
seq: u64,
token: String,
limits: Limits,
next_client: u32,
peers: BTreeMap<u32, Peer>,
presence: BTreeMap<u32, Presence>,
acked: BTreeMap<u32, u64>,
/// Deleted node ids: only these may be created again (by undo); new ids must come from the
/// creating client's id space.
deleted: BTreeSet<NodeId>,
}
impl Server {
/// A session for `doc`. Joining requires `token` (empty: no token). Client ids start above every
/// client id space already used in the document, so ids from earlier sessions are never handed
/// out again.
pub fn new(doc: Document, token: impl Into<String>, limits: Limits) -> Server {
let used = doc.ids().map(|id| id.0 >> 32).max().unwrap_or(0);
let next_client = u32::try_from(used.saturating_add(1)).unwrap_or(u32::MAX).max(1);
Server {
state: Replica::new(doc),
seq: 0,
token: token.into(),
limits,
next_client,
peers: BTreeMap::new(),
presence: BTreeMap::new(),
acked: BTreeMap::new(),
deleted: BTreeSet::new(),
}
}
/// The authoritative document.
pub fn document(&self) -> &Document {
self.state.document()
}
/// The authoritative state (document and positions).
pub fn replica(&self) -> &Replica {
&self.state
}
/// The sequence number of the last accepted batch (0: none yet).
pub fn seq(&self) -> u64 {
self.seq
}
/// Who is in the session.
pub fn peers(&self) -> impl Iterator<Item = &Peer> {
self.peers.values()
}
/// The highest sequence number client `id` reported applying.
pub fn acked(&self, id: u32) -> Option<u64> {
self.acked.get(&id).copied()
}
/// A new connection says hello: on success its client id and the messages to send (its
/// `Welcome`, everyone else's `PeerJoined`); otherwise the `Refused` to send back.
pub fn join(&mut self, hello: Hello) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
if hello.protocol != PROTOCOL {
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
}
if !same_secret(&hello.token, &self.token) {
return Err(refuse("wrong session token"));
}
if self.peers.len() >= self.limits.max_peers {
return Err(refuse("the session is full"));
}
if hello.name.chars().count() > self.limits.max_name || hello.color.chars().count() > self.limits.max_name {
return Err(refuse("name or colour too long"));
}
if self.next_client == u32::MAX {
return Err(refuse("no client ids left in this session"));
}
let snapshot = self.state.snapshot().map_err(|e| refuse(&e.to_string()))?;
let id = self.next_client;
self.next_client += 1;
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
let welcome = ServerMsg::Welcome { client_id: id, seq: self.seq, snapshot, peers: self.peers.values().cloned().collect() };
self.peers.insert(id, peer.clone());
Ok((id, vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))]))
}
/// Client `id`'s connection closed.
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
self.acked.remove(&id);
match self.peers.remove(&id) {
Some(_) => vec![Outgoing::Others(id, ServerMsg::PeerLeft { client_id: id })],
None => Vec::new(),
}
}
/// A message from client `from` (which has joined).
pub fn receive(&mut self, from: u32, msg: ClientMsg) -> Vec<Outgoing> {
if !self.peers.contains_key(&from) {
return vec![Outgoing::To(from, ServerMsg::Refused { reason: "say hello first".into() })];
}
match msg {
ClientMsg::Hello(_) => vec![Outgoing::To(from, ServerMsg::Refused { reason: "already joined".into() })],
ClientMsg::Changes { base_seq, batch_id, changes } => match self.accept(from, base_seq, &changes) {
Ok(()) => vec![Outgoing::All(ServerMsg::Changes { seq: self.seq, author: from, batch_id, changes })],
Err(e) => {
let fixes = self.fixes(&changes).unwrap_or_default();
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: e.to_string(), fixes })]
}
},
ClientMsg::Presence(p) => {
self.presence.insert(from, p.clone());
vec![Outgoing::Others(from, ServerMsg::Presence { client_id: from, presence: p })]
}
ClientMsg::Ack { seq } => {
self.acked.insert(from, seq.min(self.seq));
Vec::new()
}
ClientMsg::Ping => vec![Outgoing::To(from, ServerMsg::Pong)],
}
}
/// Validate and apply a batch; on success it has the next sequence number.
fn accept(&mut self, from: u32, base_seq: u64, changes: &[Change]) -> Result<(), Error> {
if changes.len() > self.limits.max_changes {
return Err(Error::Invalid(format!("{} changes in one batch (at most {})", changes.len(), self.limits.max_changes)));
}
if base_seq > self.seq {
return Err(Error::Protocol(format!("based on sequence {base_seq}, but the server is at {}", self.seq)));
}
for c in changes {
if let Change::Create { id, .. } = c
&& !self.state.document().contains(*id)
&& !self.deleted.contains(id)
&& id.0 >> 32 != u64::from(from)
{
return Err(Error::Invalid(format!("new node {id} isn't in client {from}'s id space")));
}
}
let mut next = self.state.clone();
let applied = next.apply(changes, Mode::Strict)?;
if next.document().node_count() > self.limits.max_nodes {
return Err(Error::Invalid(format!("more than {} layers", self.limits.max_nodes)));
}
for c in changes {
if let Change::Create { id, .. } = c {
self.deleted.remove(id);
}
}
self.deleted.extend(applied.deleted);
self.state = next;
self.seq += 1;
Ok(())
}
/// The current values of what a refused batch touched, so a client can roll its optimistic
/// copy back: nodes that don't exist are deleted, deleted subtrees come back whole, edited
/// properties get their current values, document-level data likewise.
fn fixes(&self, changes: &[Change]) -> Result<Vec<Change>, Error> {
let doc = self.state.document();
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
let mut out = Vec::new();
for c in changes {
match c {
Change::Create { id, .. } => {
props.entry(*id).or_default().extend(Prop::FIELDS.iter().copied().chain([Prop::Parent, Prop::Position]));
}
Change::Set { id, prop, .. } => {
props.entry(*id).or_default().insert(*prop);
}
Change::Delete { id } => {
gone.insert(*id);
}
other => {
if let Some(fix) = docprops::current(doc, other)?
&& !out.contains(&fix)
{
out.push(fix);
}
}
}
}
let mut nodes = Vec::new();
for (id, ps) in props {
let Some(n) = doc.node(id) else {
nodes.push(Change::Delete { id });
continue;
};
for p in ps {
let value = match p {
Prop::Parent => crate::Json::of(&n.parent)?,
Prop::Position => crate::Json::of(self.state.key(id))?,
p => p.get(n)?,
};
nodes.push(Change::Set { id, prop: p, value });
}
}
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if let Some(n) = doc.node(d) {
nodes.push(create_change(n, self.state.key(d))?);
}
}
}
None => nodes.push(Change::Delete { id }),
}
}
nodes.extend(out);
Ok(nodes)
}
}
/// Compare secrets without stopping at the first difference.
fn same_secret(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

View File

@ -0,0 +1,302 @@
//! The server's ordering and validation, and the client's reconciliation, message by message.
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
use crate::replica::Mode;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
fn hello(token: &str) -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", token, "Ada", "#FF8800") else { panic!("not a hello") };
h
}
fn doc() -> (Document, NodeId, NodeId) {
let mut d = Document::new("Shared");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 100.0, 100.0).unwrap();
(d, page, frame)
}
/// Join and return the client built from its welcome.
fn join(server: &mut Server) -> Client {
let (id, out) = server.join(hello("tok")).unwrap();
let Some(Outgoing::To(to, welcome)) = out.into_iter().next() else { panic!("no welcome") };
assert_eq!(to, id);
Client::join(welcome).unwrap()
}
/// The message the server sends `to` among `out`.
fn for_client(out: &[Outgoing], to: u32) -> Vec<ServerMsg> {
out.iter()
.filter_map(|o| match o {
Outgoing::To(t, m) if *t == to => Some(m.clone()),
Outgoing::All(m) => Some(m.clone()),
Outgoing::Others(not, m) if *not != to => Some(m.clone()),
_ => None,
})
.collect()
}
fn edit(c: &mut Client, f: impl FnOnce(&mut Document)) -> ClientMsg {
let mut d = c.document().clone();
f(&mut d);
c.edit(&d).unwrap().expect("a change")
}
#[test]
fn joining_checks_protocol_token_and_room() {
let (d, _, _) = doc();
let mut s = Server::new(d, "tok", Limits { max_peers: 2, ..Limits::default() });
assert!(matches!(s.join(Hello { protocol: PROTOCOL + 1, ..hello("tok") }), Err(ServerMsg::Refused { .. })));
assert!(matches!(s.join(hello("nope")), Err(ServerMsg::Refused { .. })));
assert!(matches!(s.join(Hello { name: "x".repeat(65), ..hello("tok") }), Err(ServerMsg::Refused { .. })));
let a = join(&mut s);
let (b_id, out) = s.join(hello("tok")).unwrap();
assert!(
out.contains(&Outgoing::Others(b_id, ServerMsg::PeerJoined(crate::Peer { client_id: b_id, name: "Ada".into(), color: "#FF8800".into() })))
);
assert!(matches!(s.join(hello("tok")), Err(ServerMsg::Refused { .. })), "full");
assert_ne!(a.id(), b_id);
assert_eq!(a.document(), s.document(), "the welcome carries the document");
assert_eq!(a.view(), s.replica());
// Leaving frees the seat and tells the others.
assert_eq!(s.leave(b_id), vec![Outgoing::Others(b_id, ServerMsg::PeerLeft { client_id: b_id })]);
assert!(s.leave(b_id).is_empty());
assert!(s.join(hello("tok")).is_ok());
}
#[test]
fn client_ids_skip_spaces_used_in_the_file() {
let (mut d, page, _) = doc();
d.insert(Node::new(NodeId((7 << 32) | 3), NodeKind::Rectangle, "from session 7", 1.0, 1.0), page, None).unwrap();
let mut s = Server::new(d, "tok", Limits::default());
assert_eq!(join(&mut s).id(), 8);
}
#[test]
fn batches_are_ordered_echoed_and_rebroadcast() {
let (d, page, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let mut a = join(&mut s);
let mut b = join(&mut s);
let msg = edit(&mut a, |d| {
d.create(frame, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
});
assert_eq!(a.pending(), 1);
let out = s.receive(a.id(), msg);
assert_eq!(s.seq(), 1);
for m in for_client(&out, a.id()) {
assert!(a.receive(m, &NoRecompute).unwrap().is_empty());
}
for m in for_client(&out, b.id()) {
b.receive(m, &NoRecompute).unwrap();
}
assert_eq!(a.pending(), 0);
assert_eq!(a.view(), s.replica());
assert_eq!(b.view(), s.replica());
// The new node's id is in a's space.
let r = s.document().node(frame).unwrap().children[0];
assert_eq!(r.0 >> 32, u64::from(a.id()));
// Ping, ack, presence.
assert_eq!(s.receive(a.id(), ClientMsg::Ping), vec![Outgoing::To(a.id(), ServerMsg::Pong)]);
assert!(s.receive(a.id(), ClientMsg::Ack { seq: 99 }).is_empty());
assert_eq!(s.acked(a.id()), Some(1));
let p = Presence { cursor: Some((page, 1.0, 2.0)), ..Default::default() };
let out = s.receive(a.id(), ClientMsg::Presence(p.clone()));
for m in for_client(&out, b.id()) {
b.receive(m, &NoRecompute).unwrap();
}
assert_eq!(b.presence_of(a.id()), Some(&p));
assert!(for_client(&out, a.id()).is_empty(), "not echoed");
// Out-of-place messages.
assert!(matches!(s.receive(a.id(), ClientMsg::Hello(hello("tok"))).as_slice(), [Outgoing::To(_, ServerMsg::Refused { .. })]));
assert!(matches!(s.receive(999, ClientMsg::Ping).as_slice(), [Outgoing::To(999, ServerMsg::Refused { .. })]));
}
fn rejected(s: &mut Server, from: u32, changes: Vec<Change>) -> (String, Vec<Change>) {
let before = s.replica().clone();
let out = s.receive(from, ClientMsg::Changes { base_seq: s.seq(), batch_id: 1, changes });
assert_eq!(s.replica(), &before, "nothing applied");
match out.as_slice() {
[Outgoing::To(to, ServerMsg::Reject { batch_id: 1, reason, fixes })] if *to == from => (reason.clone(), fixes.clone()),
other => panic!("not rejected: {other:?}"),
}
}
#[test]
fn invalid_batches_are_rejected_whole() {
let (d, page, frame) = doc();
let mut s = Server::new(d, "tok", Limits { max_changes: 3, ..Limits::default() });
let a = join(&mut s);
let me = u64::from(a.id());
let create = |id: u64, parent: NodeId| Change::Create {
id: NodeId(id),
kind: Json::of(&NodeKind::Frame).unwrap(),
parent: Some(parent),
position: "a5".into(),
props: vec![],
};
let set = |id: NodeId, prop: Prop, v: serde_json::Value| Change::Set { id, prop, value: Json(v) };
// Someone else's id space, or the old ids of the file.
rejected(&mut s, a.id(), vec![create(((me + 1) << 32) | 1, frame)]);
rejected(&mut s, a.id(), vec![create(77, frame)]);
// A valid change with an invalid one: neither applies.
let (reason, _) = rejected(&mut s, a.id(), vec![set(frame, Prop::Name, "ok".into()), set(NodeId(4242), Prop::Name, "x".into())]);
assert!(reason.contains("4242"), "{reason}");
// A cycle through a new frame.
rejected(&mut s, a.id(), vec![create((me << 32) | 1, frame), set(frame, Prop::Parent, serde_json::json!((me << 32) | 1))]);
// Too many changes; based on the future.
rejected(&mut s, a.id(), vec![set(frame, Prop::Name, "a".into()); 4]);
let out = s.receive(a.id(), ClientMsg::Changes { base_seq: 5, batch_id: 1, changes: vec![] });
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { .. })]));
// Deleting the last page.
rejected(&mut s, a.id(), vec![Change::Delete { id: page }]);
assert_eq!(s.seq(), 0);
}
#[test]
fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let mut a = join(&mut s);
let b = join(&mut s);
let gone = s.replica().clone();
let snap = crate::replica::create_change(gone.document().node(frame).unwrap(), gone.key(frame)).unwrap();
// b deletes the frame (an old id); a brings it back, as undo would.
let out = s.receive(b.id(), ClientMsg::Changes { base_seq: 0, batch_id: 1, changes: vec![Change::Delete { id: frame }] });
assert!(matches!(out.as_slice(), [Outgoing::All(ServerMsg::Changes { .. })]));
for m in for_client(&out, a.id()) {
a.receive(m, &NoRecompute).unwrap();
}
let out = s.receive(a.id(), ClientMsg::Changes { base_seq: 1, batch_id: 1, changes: vec![snap.clone()] });
assert!(matches!(out.as_slice(), [Outgoing::All(ServerMsg::Changes { .. })]), "{out:?}");
assert_eq!(s.document(), gone.document());
}
#[test]
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let mut a = join(&mut s);
let mut b = join(&mut s);
// b deletes the frame; meanwhile a renames it and adds a rectangle inside.
let del = edit(&mut b, |d| d.remove(frame).unwrap());
let mine = edit(&mut a, |d| {
d.get_mut(frame).unwrap().name = "Mine".into();
d.create(frame, NodeKind::Rectangle, "R", 0.0, 0.0, 5.0, 5.0).unwrap();
});
let optimistic = a.view().clone();
let out_b = s.receive(b.id(), del);
let out_a = s.receive(a.id(), mine);
let [Outgoing::To(_, ServerMsg::Reject { fixes, .. })] = out_a.as_slice() else { panic!("{out_a:?}") };
// A client without a confirmed copy can roll back with the fixes: they delete what the server
// doesn't have (b's delete arrives first on a's connection).
let mut rolled = optimistic.clone();
let ServerMsg::Changes { changes, .. } = &for_client(&out_b, a.id())[0] else { panic!() };
rolled.apply(changes, Mode::Lenient).unwrap();
rolled.apply(fixes, Mode::Lenient).unwrap();
assert_eq!(rolled.document(), s.document());
// The real client replays from its confirmed copy instead.
for m in for_client(&out_b, a.id()).into_iter().chain(for_client(&out_a, a.id())) {
a.receive(m, &NoRecompute).unwrap();
}
for m in for_client(&out_b, b.id()) {
b.receive(m, &NoRecompute).unwrap();
}
assert_eq!(a.pending(), 0);
assert_eq!(a.view(), s.replica());
assert_eq!(b.view(), s.replica());
}
#[test]
fn remote_batches_apply_under_pending_ones() {
let (d, _, frame) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let mut a = join(&mut s);
let mut b = join(&mut s);
// a renames the frame and b moves it, at the same time; b's lands first.
let rename = edit(&mut a, |d| d.get_mut(frame).unwrap().name = "A's name".into());
let mv = edit(&mut b, |d| d.get_mut(frame).unwrap().transform = figcraft_geom::Affine::translate((50.0, 0.0)));
let out_b = s.receive(b.id(), mv);
for m in for_client(&out_b, a.id()) {
a.receive(m, &NoRecompute).unwrap();
}
// a sees b's move and keeps its own pending rename.
let n = a.document().node(frame).unwrap();
assert_eq!((n.name.as_str(), n.transform.translation().x), ("A's name", 50.0));
assert_eq!(a.pending(), 1);
let out_a = s.receive(a.id(), rename);
for m in for_client(&out_a, a.id()) {
a.receive(m, &NoRecompute).unwrap();
}
for m in for_client(&out_b, b.id()).into_iter().chain(for_client(&out_a, b.id())) {
b.receive(m, &NoRecompute).unwrap();
}
assert_eq!(a.view(), s.replica());
assert_eq!(b.view(), s.replica());
// Messages out of order are protocol errors.
let stale = ServerMsg::Changes { seq: 1, author: b.id(), batch_id: 1, changes: vec![] };
assert!(a.receive(stale, &NoRecompute).is_err());
assert!(a.receive(ServerMsg::Reject { batch_id: 9, reason: String::new(), fixes: vec![] }, &NoRecompute).is_err());
assert!(Client::join(ServerMsg::Pong).is_err());
}
#[test]
fn junk_batches_never_panic_and_never_corrupt_the_document() {
use crate::change::{DocProp, Entry};
use crate::sim::Rng;
let (d, _, _) = doc();
let mut s = Server::new(d, "tok", Limits::default());
let a = join(&mut s);
let me = u64::from(a.id());
let mut rng = Rng::new(5);
let values = [
serde_json::json!(null),
serde_json::json!(-1),
serde_json::json!(1e300),
serde_json::json!("a0V"),
serde_json::json!("x"),
serde_json::json!([1, 2, 3]),
serde_json::json!({"type": "frame"}),
serde_json::json!({"type": "page"}),
serde_json::json!({"type": "polygon", "count": 4_000_000_000u64}),
serde_json::json!([1.0, 0.0, 0.0, 1.0, 5.0, 5.0]),
serde_json::json!(true),
];
for _ in 0..3000 {
let ids: Vec<NodeId> = s.document().ids().collect();
let id = |rng: &mut Rng| match rng.below(3) {
0 => NodeId((me << 32) | rng.below(50) as u64),
1 => NodeId(rng.below(10) as u64),
_ => rng.pick(&ids).copied().unwrap_or(NodeId(1)),
};
let v = |rng: &mut Rng| Json(rng.pick(&values).cloned().unwrap_or_default());
let n = 1 + rng.below(4);
let mut changes = Vec::new();
for _ in 0..n {
let target = id(&mut rng);
let parent = if rng.chance(0.2) { None } else { Some(id(&mut rng)) };
changes.push(match rng.below(7) {
0 => {
Change::Create { id: target, kind: v(&mut rng), parent, position: ["a0", "a1", "Zz", "bad"][rng.below(4)].into(), props: vec![] }
}
1 | 2 => Change::Set { id: target, prop: *rng.pick(Prop::FIELDS).unwrap_or(&Prop::Name), value: v(&mut rng) },
3 => Change::Set {
id: target,
prop: if rng.chance(0.5) { Prop::Parent } else { Prop::Position },
value: Json(serde_json::json!(id(&mut rng).0)),
},
4 => Change::Delete { id: target },
5 => Change::SetDoc {
prop: [DocProp::Name, DocProp::StyleOrder, DocProp::Libraries, DocProp::CurrentPage][rng.below(4)],
value: v(&mut rng),
},
_ => Change::Put { entry: Entry::Comment(rng.below(3) as u64), value: v(&mut rng) },
});
}
let _ = s.receive(a.id(), ClientMsg::Changes { base_seq: s.seq(), batch_id: 1, changes });
assert!(s.document().validate().is_empty(), "{:?}", s.document().validate());
}
}

273
crates/collab/src/sim.rs Normal file
View File

@ -0,0 +1,273 @@
//! An in-memory session for tests: one [`Server`], several [`Client`]s and a link each way per
//! client that delivers in order (like a WebSocket) after a random delay, so messages on different
//! links interleave differently for every seed. Every message goes through the binary or the JSON
//! encoding on its way.
use std::collections::VecDeque;
use figcraft_doc::Document;
use crate::message::{self, ClientMsg, ServerMsg};
use crate::{Client, Error, Limits, Outgoing, Recompute, Replica, Server};
/// A small deterministic random number generator (SplitMix64) for simulations and fuzzing.
#[derive(Clone, Debug)]
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
Rng(seed)
}
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A number in `0..n` (0 when `n` is 0).
pub fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
/// True with probability `p`.
pub fn chance(&mut self, p: f64) -> bool {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 <= p
}
/// A random element of `items`.
pub fn pick<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> {
items.get(self.below(items.len()))
}
}
/// A message on the wire.
#[derive(Clone, Debug)]
enum Wire {
Binary(Vec<u8>),
Json(String),
}
/// One direction of one connection: in order, each message due at a time.
#[derive(Clone, Debug, Default)]
struct Link {
queue: VecDeque<(u64, Wire)>,
last_due: u64,
}
/// A simulated session.
pub struct Sim {
pub server: Server,
clients: Vec<Client>,
up: Vec<Link>,
down: Vec<Link>,
rng: Rng,
now: u64,
max_delay: u64,
limits: Limits,
/// Messages delivered so far.
pub delivered: usize,
/// Batches the server rejected.
pub rejected: usize,
/// Follow-up batches clients sent to repair derived data.
pub repairs: usize,
}
impl Sim {
/// A session for `doc` with `n` clients joined; message delays are random in `1..=max_delay`.
pub fn new(doc: Document, n: usize, seed: u64, max_delay: u64) -> Result<Sim, Error> {
let limits = Limits::default();
let mut sim = Sim {
server: Server::new(doc, "secret", limits),
clients: Vec::new(),
up: Vec::new(),
down: Vec::new(),
rng: Rng::new(seed),
now: 0,
max_delay: max_delay.max(1),
limits,
delivered: 0,
rejected: 0,
repairs: 0,
};
for i in 0..n {
let ClientMsg::Hello(hello) = Client::hello("file", "secret", &format!("Person {}", i + 1), "#3366FF") else {
return Err(Error::Protocol("hello".into()));
};
let (id, out) = sim.server.join(hello).map_err(|m| Error::Protocol(format!("{m:?}")))?;
let mut welcome = None;
let mut rest = Vec::new();
for o in out {
match o {
Outgoing::To(to, m) if to == id => welcome = Some(m),
o => rest.push(o),
}
}
let welcome = welcome.ok_or_else(|| Error::Protocol("no welcome".into()))?;
// The welcome travels encoded like everything else.
let welcome: ServerMsg = message::decode(&message::encode(&welcome)?, limits.max_message_bytes)?;
sim.clients.push(Client::join(welcome)?);
sim.up.push(Link::default());
sim.down.push(Link::default());
sim.route(rest)?;
}
Ok(sim)
}
/// The clients.
pub fn clients(&self) -> &[Client] {
&self.clients
}
/// Client `i`'s document (to edit a copy of).
pub fn document(&self, i: usize) -> Option<&Document> {
self.clients.get(i).map(Client::document)
}
/// The random number generator (drivers use it to pick edits).
pub fn rng(&mut self) -> &mut Rng {
&mut self.rng
}
fn index_of(&self, id: u32) -> Option<usize> {
self.clients.iter().position(|c| c.id() == id)
}
fn wire<T: serde::Serialize>(&mut self, msg: &T) -> Result<Wire, Error> {
// Mostly binary; now and then the JSON form, so both encodings are exercised.
if self.rng.chance(0.15) { message::encode_json(msg).map(Wire::Json) } else { message::encode(msg).map(Wire::Binary) }
}
fn unwire<T: serde::de::DeserializeOwned>(&self, w: &Wire) -> Result<T, Error> {
match w {
Wire::Binary(b) => message::decode(b, self.limits.max_message_bytes),
Wire::Json(t) => message::decode_json(t, self.limits.max_message_bytes),
}
}
fn send(&mut self, up: bool, i: usize, w: Wire) {
let delay = 1 + self.rng.next_u64() % self.max_delay;
let now = self.now;
let link = if up { self.up.get_mut(i) } else { self.down.get_mut(i) };
if let Some(link) = link {
let due = (now + delay).max(link.last_due);
link.last_due = due;
link.queue.push_back((due, w));
}
}
fn route(&mut self, out: Vec<Outgoing>) -> Result<(), Error> {
for o in out {
let (msg, targets): (ServerMsg, Vec<usize>) = match o {
Outgoing::To(id, m) => (m, self.index_of(id).into_iter().collect()),
Outgoing::All(m) => (m, (0..self.clients.len()).collect()),
Outgoing::Others(id, m) => (m, (0..self.clients.len()).filter(|i| self.clients.get(*i).is_some_and(|c| c.id() != id)).collect()),
};
for t in targets {
let w = self.wire(&msg)?;
self.send(false, t, w);
}
}
Ok(())
}
/// Client `i` edited its document into `doc`: it applies it at once and sends the batch.
/// Returns whether anything synced changed.
pub fn edit(&mut self, i: usize, doc: &Document) -> Result<bool, Error> {
let c = self.clients.get_mut(i).ok_or_else(|| Error::Invalid(format!("no client {i}")))?;
match c.edit(doc)? {
Some(msg) => {
let w = self.wire(&msg)?;
self.send(true, i, w);
Ok(true)
}
None => Ok(false),
}
}
/// Deliver the next due message (the earliest; ties go to the lowest link). False when none.
pub fn step(&mut self, recompute: &dyn Recompute) -> Result<bool, Error> {
let next = self
.up
.iter()
.enumerate()
.filter_map(|(i, l)| l.queue.front().map(|(due, _)| (*due, 0u8, i)))
.chain(self.down.iter().enumerate().filter_map(|(i, l)| l.queue.front().map(|(due, _)| (*due, 1u8, i))))
.min();
let Some((due, dir, i)) = next else { return Ok(false) };
self.now = self.now.max(due);
self.delivered += 1;
if dir == 0 {
let Some((_, w)) = self.up.get_mut(i).and_then(|l| l.queue.pop_front()) else { return Ok(false) };
let msg: ClientMsg = self.unwire(&w)?;
let id = self.clients.get(i).map(Client::id).unwrap_or(0);
let out = self.server.receive(id, msg);
self.route(out)?;
} else {
let Some((_, w)) = self.down.get_mut(i).and_then(|l| l.queue.pop_front()) else { return Ok(false) };
let msg: ServerMsg = self.unwire(&w)?;
self.rejected += usize::from(matches!(msg, ServerMsg::Reject { .. }));
let replies = match self.clients.get_mut(i) {
Some(c) => c.receive(msg, recompute)?,
None => Vec::new(),
};
self.repairs += replies.len();
for r in replies {
let w = self.wire(&r)?;
self.send(true, i, w);
}
}
Ok(true)
}
/// Deliver until no message is in flight (at most `max_steps`). Returns the steps taken.
pub fn settle(&mut self, recompute: &dyn Recompute, max_steps: usize) -> Result<usize, Error> {
let mut n = 0;
while n < max_steps && self.step(recompute)? {
n += 1;
}
if self.up.iter().chain(&self.down).any(|l| !l.queue.is_empty()) {
return Err(Error::Protocol(format!("still busy after {max_steps} steps")));
}
Ok(n)
}
/// Whether every client caught up with the server and holds exactly its state; the first
/// difference otherwise.
pub fn converged(&self) -> Result<(), String> {
let server: &Replica = self.server.replica();
for c in &self.clients {
if c.pending() > 0 || c.seq() != self.server.seq() {
return Err(format!("client {} at seq {} with {} pending; server at {}", c.id(), c.seq(), c.pending(), self.server.seq()));
}
if c.view() != server || c.confirmed() != server {
return Err(format!("client {}: {}", c.id(), describe(server, c.view())));
}
}
Ok(())
}
}
/// The first difference between two replicas, for test failures.
pub fn describe(a: &Replica, b: &Replica) -> String {
let (da, db) = (a.document(), b.document());
if let Some(&id) = db.changed_ids(da, usize::MAX).unwrap_or_default().first() {
return match (da.node(id), db.node(id)) {
(Some(x), Some(y)) => format!("node {id} differs:\n {x:?}\n {y:?}"),
(x, y) => format!("node {id}: {} vs {}", x.is_some(), y.is_some()),
};
}
if da.pages != db.pages {
return format!("pages {:?} vs {:?}", da.pages, db.pages);
}
if a.positions() != b.positions() {
let k = a.positions().iter().find(|(k, v)| b.positions().get(*k) != Some(*v)).map(|(k, _)| *k);
return format!("positions differ (first at {k:?})");
}
if da != db {
return "document-level data differs".into();
}
"equal".into()
}

View File

@ -0,0 +1,166 @@
//! Convergence: several clients editing at once over links with random delays end up with exactly
//! the server's state (synthetic edits; the engine's tests drive real commands).
use std::collections::BTreeSet;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind};
use crate::sim::{Rng, Sim};
use crate::{NoRecompute, Recompute};
/// A toy derived value: a frame's width is ten times its number of children.
struct Width;
impl Recompute for Width {
fn recompute(&self, doc: &mut Document, _: &Document, touched: &BTreeSet<NodeId>) {
for id in touched {
let want = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.children.len() as f64 * 10.0);
if let Some(w) = want
&& doc.node(*id).is_some_and(|n| n.width != w)
&& let Some(n) = doc.node_mut(*id)
{
n.width = w;
}
}
}
}
fn base() -> Document {
let mut d = Document::new("Shared");
let page = d.first_page().unwrap();
for i in 0..3 {
let f = d.create(page, NodeKind::Frame, &format!("F{i}"), i as f64 * 200.0, 0.0, 0.0, 100.0).unwrap();
for j in 0..3 {
d.create(f, NodeKind::Rectangle, &format!("R{i}{j}"), 0.0, j as f64 * 20.0, 10.0, 10.0).unwrap();
}
d.get_mut(f).unwrap().width = 30.0;
}
d.take_dirty();
d
}
/// One random edit of `doc`, the way a command would make it (derived data recomputed after).
fn random_edit(doc: &mut Document, rng: &mut Rng, derive: &dyn Recompute) {
let before = doc.clone();
doc.take_dirty();
let ids: Vec<NodeId> = doc.ids().collect();
let containers: Vec<NodeId> = ids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| n.kind.is_container())).collect();
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())).collect();
let pick = |rng: &mut Rng, v: &[NodeId]| rng.pick(v).copied();
match rng.below(14) {
0 | 1 | 12 => {
if let Some(p) = pick(rng, &containers) {
let kind = if rng.chance(0.3) { NodeKind::Frame } else { NodeKind::Rectangle };
let _ = doc.create(p, kind, "New", rng.below(500) as f64, rng.below(500) as f64, 10.0, 10.0);
}
}
2 => {
// Reparent: cycles are refused locally (and concurrent ones by the server).
if let (Some(x), Some(p)) = (pick(rng, &layers), pick(rng, &containers)) {
let at = rng.below(4);
let _ = doc.place(x, p, at);
}
}
3 => {
if let Some(x) = pick(rng, &layers) {
let at = rng.below(5);
let _ = doc.reorder(x, at);
}
}
4 => {
// Deletions take whole subtrees: keep them rarer than creations.
if let Some(x) = pick(rng, &layers).filter(|_| rng.chance(0.5)) {
let _ = doc.remove(x);
}
}
5..=7 => {
if let Some(n) = pick(rng, &layers).and_then(|x| doc.node_mut(x)) {
n.transform = figcraft_geom::Affine::translate((rng.below(800) as f64 * 0.5, rng.below(800) as f64 * 0.25));
}
}
8 => {
if let Some(n) = pick(rng, &layers).and_then(|x| doc.node_mut(x)) {
n.name = format!("Name {}", rng.below(100));
n.opacity = rng.below(11) as f32 / 10.0;
}
}
9 => {
if let Some(n) = pick(rng, &layers).and_then(|x| doc.node_mut(x)) {
n.fills = vec![Paint::solid(Color::from_rgb8_const(rng.below(256) as u8, 0, 0))];
}
}
10 => {
if rng.chance(0.5) || doc.pages.len() < 2 {
doc.add_page("Page");
} else if let Some(p) = pick(rng, &doc.pages.clone()) {
let _ = doc.remove(p);
}
}
11 => {
if doc.pages.len() > 1 {
doc.pages.rotate_left(1);
}
}
_ => {
let id = format!("style{}", rng.below(6));
match doc.styles.iter().position(|s| s.id == id) {
Some(i) if rng.chance(0.4) => {
doc.styles.remove(i);
}
Some(i) => doc.styles[i].name = format!("Renamed {}", rng.below(100)),
None => doc.styles.push(Style { id, name: "S".into(), description: String::new(), kind: StyleKind::Paint { paints: vec![] } }),
}
}
}
let touched = doc.take_dirty();
derive.recompute(doc, &before, &touched);
doc.take_dirty();
}
fn run(seed: u64, clients: usize, rounds: usize, derive: &dyn Recompute) -> Sim {
let mut sim = Sim::new(base(), clients, seed, 12).unwrap();
for _ in 0..rounds {
let i = sim.rng().below(clients);
let mut doc = sim.document(i).unwrap().clone();
let mut rng = Rng::new(sim.rng().next_u64());
random_edit(&mut doc, &mut rng, derive);
sim.edit(i, &doc).unwrap();
for _ in 0..sim.rng().below(4) {
sim.step(derive).unwrap();
}
}
sim.settle(derive, 100_000).unwrap();
if let Err(e) = sim.converged() {
panic!("seed {seed}: {e}");
}
sim
}
#[test]
fn clients_converge_on_the_server_state() {
for seed in 0..12 {
let sim = run(seed, 4, 60, &NoRecompute);
assert!(sim.delivered > 60);
assert!(sim.server.document().validate().is_empty());
}
}
#[test]
fn derived_data_is_repaired_after_concurrent_edits() {
for seed in 100..110 {
let sim = run(seed, 3, 50, &Width);
let doc = sim.server.document();
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)) {
assert_eq!(n.width, n.children.len() as f64 * 10.0, "seed {seed}: frame {} has {} children", n.id, n.children.len());
}
}
}
#[test]
#[ignore = "longer run for development: cargo test -p figcraft-collab -- --ignored"]
fn many_seeds_converge() {
for seed in 1000..1300 {
run(seed, 4, 120, &Width);
}
}

View File

@ -0,0 +1,127 @@
//! Client-scoped ids (collaboration), raw structure edits and page-less documents.
use crate::{DocError, Document, Node, NodeId, NodeKind};
use figcraft_geom::Affine;
fn rect(doc: &mut Document, parent: NodeId) -> NodeId {
doc.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap()
}
const C3: u64 = 3 << 32;
const C5: u64 = 5 << 32;
#[test]
fn single_user_allocation_is_unchanged() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
assert_eq!(page, NodeId(1));
assert_eq!(d.client_id(), 0);
assert_eq!(rect(&mut d, page), NodeId(2));
assert_eq!(d.next_id(), NodeId(3));
// Inserting any id above the counter moves it, as before.
d.insert(Node::new(NodeId(C5 | 9), NodeKind::Rectangle, "x", 1.0, 1.0), page, None).unwrap();
assert_eq!(d.next_id(), NodeId((C5 | 9) + 1));
}
#[test]
fn clients_allocate_in_their_own_space_and_never_collide() {
let mut base = Document::new("a");
let page = base.first_page().unwrap();
rect(&mut base, page);
let (mut a, mut b) = (base.clone(), base.clone());
a.set_client_id(3);
b.set_client_id(5);
let ia: Vec<NodeId> = (0..3).map(|_| rect(&mut a, page)).collect();
let ib: Vec<NodeId> = (0..3).map(|_| rect(&mut b, page)).collect();
assert_eq!(ia, vec![NodeId(C3 | 1), NodeId(C3 | 2), NodeId(C3 | 3)]);
assert_eq!(ib, vec![NodeId(C5 | 1), NodeId(C5 | 2), NodeId(C5 | 3)]);
// Someone else's ids arriving don't move this client's counter; its own higher ids do.
a.insert(Node::new(NodeId(C5 | 77), NodeKind::Rectangle, "theirs", 1.0, 1.0), page, None).unwrap();
assert_eq!(a.next_id(), NodeId(C3 | 4));
a.insert(Node::new(NodeId(C3 | 40), NodeKind::Rectangle, "mine", 1.0, 1.0), page, None).unwrap();
assert_eq!(a.alloc_id(), NodeId(C3 | 41));
// Old ids (client 0) don't count either.
a.reserve_ids(NodeId(1_000));
assert_eq!(a.next_id(), NodeId(C3 | 42));
}
#[test]
fn setting_a_client_continues_after_its_existing_ids() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
d.insert(Node::new(NodeId(C3 | 7), NodeKind::Rectangle, "earlier session", 1.0, 1.0), page, None).unwrap();
d.set_client_id(3);
assert_eq!(d.client_id(), 3);
assert_eq!(d.alloc_id(), NodeId(C3 | 8));
// Setting the same client again keeps the counter (handed-out ids stay handed out).
d.set_client_id(3);
assert_eq!(d.alloc_id(), NodeId(C3 | 9));
// Back to single-user: after the highest id of any space.
d.set_client_id(0);
assert_eq!(d.alloc_id(), NodeId((C3 | 7) + 1));
// repair() keeps the client.
d.set_client_id(5);
d.repair();
assert_eq!(d.client_id(), 5);
assert_eq!(d.alloc_id(), NodeId(C5 | 1));
}
#[test]
fn the_counter_is_saved_but_never_compared_and_the_client_never_saved() {
let mut a = Document::new("a");
let mut b = a.clone();
b.set_client_id(9);
b.alloc_id();
assert_eq!(a, b, "same content, different counters");
let json = serde_json::to_value(&b).unwrap();
assert_eq!(json["next_id"], serde_json::json!((9u64 << 32) | 2));
let back: Document = serde_json::from_value(json).unwrap();
assert_eq!(back.client_id(), 0);
assert_eq!(back.next_id(), NodeId((9u64 << 32) | 2));
a.set_client_id(9);
assert_eq!(a.next_id(), NodeId((9u64 << 32) | 1));
}
#[test]
fn place_keeps_the_local_transform_and_refuses_bad_moves() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
let f = d.create(page, NodeKind::Frame, "F", 100.0, 100.0, 50.0, 50.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 10.0, 10.0, 20.0, 20.0).unwrap();
let r = rect(&mut d, page);
d.get_mut(r).unwrap().transform = Affine::translate((5.0, 6.0));
d.place(r, f, 0).unwrap();
assert_eq!(d.node(r).unwrap().transform, Affine::translate((5.0, 6.0)));
assert_eq!(d.node(r).unwrap().parent, Some(f));
assert_eq!(d.node(f).unwrap().children, vec![r, g]);
d.place(r, f, 99).unwrap();
assert_eq!(d.node(f).unwrap().children, vec![g, r], "same parent, index clamped");
assert_eq!(d.place(f, g, 0), Err(DocError::Cycle(f, g)));
assert_eq!(d.place(f, f, 0), Err(DocError::Cycle(f, f)));
assert_eq!(d.place(g, r, 0), Err(DocError::NotContainer(r)));
assert!(d.place(page, f, 0).is_err());
assert!(d.place(NodeId(999), f, 0).is_err());
assert!(d.validate().is_empty());
}
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
"P",
0.0,
0.0,
);
d.insert_page(p.clone()).unwrap();
assert_eq!(d.pages, vec![NodeId(C3 | 1)]);
assert!(d.insert_page(p).is_err(), "duplicate");
assert!(d.insert_page(Node::new(NodeId(4), NodeKind::Frame, "F", 1.0, 1.0)).is_err(), "not a page");
assert!(d.validate().is_empty());
// The page-less base repairs into a file with a page.
let mut e = Document::without_pages("x");
e.repair();
assert_eq!(e.pages.len(), 1);
}

View File

@ -85,6 +85,52 @@ impl PartialEq for Dirty {
}
}
/// Hands out node ids. A single-user document counts up through the whole id range; in a live
/// collaboration session each participant allocates in its own space, `client << 32 | counter`, so
/// ids made at the same time on different machines never collide. Saved as the file's `next_id`;
/// the client id is never saved. Never part of equality: two documents with the same content are
/// equal however far their counters went.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(transparent)]
struct IdAlloc {
/// The next id to hand out.
next: u64,
/// The client whose space ids come from (0: the whole range, as in a single-user file).
#[serde(skip)]
client: u32,
}
impl PartialEq for IdAlloc {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl IdAlloc {
/// Whether `id` lies in the space this allocator hands out (any id without a client).
fn owns(&self, id: NodeId) -> bool {
self.client == 0 || id.0 >> 32 == u64::from(self.client)
}
/// Never hand out `id` (or anything below it in this space) again.
fn reserve(&mut self, id: NodeId) {
if self.owns(id) && id.0 >= self.next {
self.next = id.0.saturating_add(1);
}
}
fn alloc(&mut self) -> NodeId {
let id = NodeId(self.next);
let next = self.next.saturating_add(1);
// A client space holds 2^32 ids; a full one keeps returning its last id (an insert then
// fails as a duplicate) rather than spilling into another client's space.
if self.owns(NodeId(next)) {
self.next = next;
}
id
}
}
/// Raw image bytes (encoded PNG/JPEG/…) kept by content hash.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageData {
@ -103,7 +149,7 @@ pub struct Document {
/// Nodes touched since the last [`take_dirty`](Self::take_dirty) (edited, added or removed).
#[serde(skip)]
dirty: Dirty,
next_id: u64,
next_id: IdAlloc,
#[serde(default)]
pub styles: Vec<Style>,
#[serde(default)]
@ -150,12 +196,21 @@ impl Default for Document {
impl Document {
/// A new file with one empty page ("Page 1").
pub fn new(name: impl Into<String>) -> Self {
let mut doc = Self {
let mut doc = Self::without_pages(name);
let p = doc.add_page("Page 1");
doc.current_page = Some(p);
doc
}
/// A document with no pages at all: not a valid file on its own (see [`repair`](Self::repair)),
/// only a base to build one onto, e.g. by applying a collaboration snapshot.
pub fn without_pages(name: impl Into<String>) -> Self {
Self {
name: name.into(),
pages: Vec::new(),
nodes: imbl::OrdMap::new(),
dirty: Dirty::default(),
next_id: 1,
next_id: IdAlloc { next: 1, client: 0 },
styles: Vec::new(),
variables: Variables::default(),
images: BTreeMap::new(),
@ -168,10 +223,7 @@ impl Document {
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
};
let p = doc.add_page("Page 1");
doc.current_page = Some(p);
doc
}
}
/// Node ids touched since the last call (edited, added or removed), clearing the record.
@ -179,16 +231,46 @@ impl Document {
std::mem::take(&mut self.dirty.0)
}
/// Record `ids` as touched, for passes that follow the dirty set (e.g. after changes made
/// elsewhere were applied).
pub fn mark_dirty(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.dirty.0.extend(ids);
}
/// Node ids touched so far (without clearing).
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
&self.dirty.0
}
/// A fresh unused id.
/// A fresh unused id (in this document's client space during a collaboration session; see
/// [`set_client_id`](Self::set_client_id)).
pub fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id = self.next_id.saturating_add(1);
id
self.next_id.alloc()
}
/// The id [`alloc_id`](Self::alloc_id) returns next.
pub fn next_id(&self) -> NodeId {
NodeId(self.next_id.next)
}
/// The collaboration client ids are allocated for (0: none, a single-user document).
pub fn client_id(&self) -> u32 {
self.next_id.client
}
/// Allocate new ids as `client << 32 | counter`, continuing after the highest id of that space
/// already in the document, so participants of a live session never make the same id. 0 goes
/// back to single-user allocation (after the highest id of any space).
pub fn set_client_id(&mut self, client: u32) {
let (lo, hi) = if client == 0 { (0, u64::MAX) } else { (u64::from(client) << 32, (u64::from(client) << 32) | u64::from(u32::MAX)) };
let highest = self.nodes.range(NodeId(lo)..=NodeId(hi)).next_back().map(|(k, _)| *k);
// Re-setting the same client keeps its counter (ids handed out stay handed out).
let keep = if self.next_id.client == client { self.next_id.next } else { 0 };
let start = if client == 0 { keep.max(1) } else { keep.max(lo | 1) };
self.next_id = IdAlloc { next: start, client };
if let Some(h) = highest {
self.next_id.reserve(h);
}
}
pub fn node(&self, id: NodeId) -> Option<&Node> {
@ -309,9 +391,7 @@ impl Document {
if self.nodes.contains_key(&id) {
return Err(DocError::Invalid(format!("node {id} already exists")));
}
if id.0 >= self.next_id {
self.next_id = id.0.saturating_add(1);
}
self.reserve_ids(id);
node.parent = Some(parent);
self.nodes.insert(id, Arc::new(node));
self.dirty.0.insert(id);
@ -445,6 +525,48 @@ impl Document {
Ok(())
}
/// Add page node `node` (its id unused; no parent, no children) at the end of the page list.
pub fn insert_page(&mut self, mut node: Node) -> DocResult<NodeId> {
let id = node.id;
if !node.is_page() {
return Err(DocError::Invalid(format!("{id} is not a page")));
}
if self.nodes.contains_key(&id) {
return Err(DocError::Invalid(format!("node {id} already exists")));
}
self.reserve_ids(id);
node.parent = None;
node.children.clear();
self.nodes.insert(id, Arc::new(node));
self.dirty.0.insert(id);
self.pages.push(id);
Ok(id)
}
/// Move `id` under `parent` at `index` (clamped) keeping its local transform, unlike
/// [`reparent`](Self::reparent), which keeps its place on the page.
pub fn place(&mut self, id: NodeId, parent: NodeId, index: usize) -> DocResult<()> {
if self.get(id)?.is_page() {
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
}
if !self.get(parent)?.kind.is_container() {
return Err(DocError::NotContainer(parent));
}
if self.is_ancestor(id, parent) {
return Err(DocError::Cycle(id, parent));
}
if let Some(old) = self.get(id)?.parent
&& let Some(p) = self.node_mut(old)
{
p.children.retain(|c| *c != id);
}
let p = self.get_mut(parent)?;
let at = index.min(p.children.len());
p.children.insert(at, id);
self.get_mut(id)?.parent = Some(parent);
Ok(())
}
/// Reorder `id` within its parent to `index`.
pub fn reorder(&mut self, id: NodeId, index: usize) -> DocResult<()> {
let parent = self.get(id)?.parent.ok_or(DocError::Invalid("page".into()))?;
@ -607,9 +729,7 @@ impl Document {
{
*component = *m;
}
if new_id.0 >= self.next_id {
self.next_id = new_id.0.saturating_add(1);
}
self.reserve_ids(*new_id);
self.nodes.insert(*new_id, Arc::new(c));
self.dirty.0.insert(*new_id);
}
@ -807,11 +927,8 @@ impl Document {
let p = self.add_page("Page 1");
self.current_page = Some(p);
}
if let Some(max) = self.nodes.keys().max()
&& self.next_id <= max.0
{
self.next_id = max.0.saturating_add(1);
}
let client = self.client_id();
self.set_client_id(client);
if self.current_page.is_none_or(|p| !self.pages.contains(&p)) {
self.current_page = self.pages.first().copied();
}
@ -875,6 +992,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
#[cfg(test)]
mod blur_tests;
#[cfg(test)]
mod client_id_tests;
#[cfg(test)]
mod colors_tests;
#[cfg(test)]
mod diff_tests;

View File

@ -282,10 +282,9 @@ impl Document {
}
/// Keep [`alloc_id`](Self::alloc_id) above `id` (for a document that reuses another's ids).
/// During a collaboration session only ids of this document's own client space count.
pub fn reserve_ids(&mut self, id: NodeId) {
if id.0 >= self.next_id {
self.next_id = id.0.saturating_add(1);
}
self.next_id.reserve(id);
}
}

View File

@ -12,6 +12,7 @@ workspace = true
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-collab = { workspace = true }
figcraft-layout = { workspace = true }
figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }

View File

@ -93,6 +93,7 @@ fn registry() -> &'static Registry {
list.extend(crate::component_codegen::specs());
list.extend(crate::annotations::specs());
list.extend(crate::distance::specs());
list.extend(crate::collab::specs());
list.extend(crate::plugins::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {

View File

@ -0,0 +1,49 @@
//! The engine's side of real-time collaboration (`figcraft_collab`): its normalize pass as the
//! recompute hook, and `collab.diff`, which shows the change batch an edit syncs as.
use std::collections::BTreeSet;
use std::sync::Arc;
use figcraft_collab::{Recompute, Replica, diff, message};
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::string;
use crate::{EngineError, Result, Session, bad, normalize, spec};
/// Derived data follows the engine's normalize pass: text boxes, groups, instances following
/// their mains, variable bindings and auto layout, recomputed over the touched nodes.
pub struct EngineRecompute;
impl Recompute for EngineRecompute {
fn recompute(&self, doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
doc.mark_dirty(touched.iter().copied());
normalize(doc, before);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"collab.diff",
"Show sync changes",
"{against? (undo: the last undoable action, the default | saved: since the last save)} → the changes a live session would send, their count and binary size",
false,
diff_cmd
)]
}
fn diff_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ds = s.ds()?;
let from: Arc<Document> = match string(p, "against").as_deref().unwrap_or("undo") {
"undo" => {
ds.history.undo.last().map(|e| e.doc.clone()).ok_or_else(|| EngineError::Disabled("collab.diff".into(), "nothing to undo".into()))?
}
"saved" => ds.saved_doc.clone(),
other => return Err(bad("collab.diff", format!("`against` is \"undo\" or \"saved\", not {other:?}"))),
};
let base = Replica::new((*from).clone());
let d = diff(&base, &ds.doc).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = message::encode(&d.changes).map_err(|e| EngineError::Other(e.to_string()))?.len();
Ok(json!({"changes": d.changes, "count": d.changes.len(), "bytes": bytes}))
}

View File

@ -0,0 +1,188 @@
//! Collaboration over real commands: every edit round-trips through its change batch, and several
//! people editing the same file at once all end up with the server's document.
use figcraft_collab::sim::{Rng, Sim};
use figcraft_collab::{Mode, NoRecompute, Recompute, Replica, diff};
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::collab::EngineRecompute;
/// A starting file: a frame with a few layers and some text.
fn base() -> Document {
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].clone();
for i in 0..3 {
s.execute("shape.rectangle", &json!({"x": 20 + i * 60, "y": 20, "width": 50, "height": 40, "parent": f})).unwrap();
}
s.execute("text.create", &json!({"text": "Hello", "x": 20, "y": 100, "parent": f})).unwrap();
s.execute("shape.ellipse", &json!({"x": 500, "y": 0, "width": 80, "height": 80})).unwrap();
// A component with an instance (instances follow edits of their main) and variables to bind.
let card = s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 120, "height": 80})).unwrap()["id"].clone();
s.execute("shape.rectangle", &json!({"x": 10, "y": 410, "width": 30, "height": 30, "parent": card})).unwrap();
let main = s.execute("component.create", &json!({"ids": [card]})).unwrap()["id"].clone();
s.execute("instance.create", &json!({"component": main, "x": 200, "y": 400})).unwrap();
s.execute("variable.createCollection", &json!({"name": "Tokens"})).unwrap();
for v in 0..4 {
s.execute("variable.create", &json!({"collection": "Tokens", "name": format!("v{v}"), "type": "color", "value": "#3366FF"})).unwrap();
}
s.doc().unwrap().clone()
}
/// A random command with parameters that make sense for `doc` (often valid, sometimes not:
/// cycles, deleted targets…).
fn random_command(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) {
let store = doc.libraries.store;
let all: Vec<&figcraft_doc::Node> = doc.nodes().filter(|n| store.is_none_or(|st| doc.page_of(n.id) != Some(st))).collect();
let ids = |f: &dyn Fn(&figcraft_doc::Node) -> bool| -> Vec<NodeId> { all.iter().filter(|n| f(n)).map(|n| n.id).collect() };
let layers = ids(&|n| !n.is_page());
let containers = ids(&|n| n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }));
let frames = ids(&|n| matches!(n.kind, NodeKind::Frame));
let components = ids(&|n| matches!(n.kind, NodeKind::Component { .. }));
let texts = ids(&|n| matches!(n.kind, NodeKind::Text(_)));
let one = |v: &[NodeId], rng: &mut Rng| rng.pick(v).copied().map_or(json!(0), |i| json!(i));
let xy = |rng: &mut Rng| (rng.below(800) as f64 - 100.0, rng.below(600) as f64 - 100.0);
let color = |rng: &mut Rng| format!("#{:06X}", rng.next_u64() & 0xFF_FFFF);
let (x, y) = xy(rng);
match rng.below(if with_undo { 30 } else { 28 }) {
0 | 1 => {
("shape.rectangle", json!({"x": x, "y": y, "width": 10 + rng.below(90), "height": 10 + rng.below(90), "parent": one(&containers, rng)}))
}
2 => ("frame.create", json!({"x": x, "y": y, "width": 100 + rng.below(200), "height": 100 + rng.below(200)})),
3 => ("text.create", json!({"text": format!("Text {}", rng.below(100)), "x": x, "y": y, "parent": one(&frames, rng)})),
4 | 5 => ("transform.move", json!({"ids": [one(&layers, rng)], "dx": rng.below(41) as f64 - 20.0, "dy": rng.below(41) as f64 - 20.0})),
6 => ("transform.resize", json!({"ids": [one(&layers, rng)], "width": 5 + rng.below(200), "height": 5 + rng.below(200)})),
7 | 8 => ("node.reparent", json!({"ids": [one(&layers, rng)], "parent": one(&containers, rng)})),
9 => ("node.reorder", json!({"id": one(&layers, rng), "index": rng.below(4)})),
10 => ("edit.delete", json!({"ids": [one(&layers, rng)]})),
11 => ("edit.duplicate", json!({"ids": [one(&layers, rng)]})),
12 => ("object.group", json!({"ids": [one(&layers, rng), one(&layers, rng)]})),
13 => ("object.ungroup", json!({"ids": [one(&layers, rng)]})),
14 => ("layout.add", json!({"ids": [one(&frames, rng)]})),
15 => ("node.set", json!({"id": one(&frames, rng), "props": {"padding": rng.below(30), "itemSpacing": rng.below(20)}})),
16 => ("component.create", json!({"ids": [one(&layers, rng)]})),
17 => ("instance.create", json!({"component": one(&components, rng), "x": x, "y": y})),
18 => ("fill.set", json!({"ids": [one(&layers, rng)], "color": color(rng)})),
19 => ("style.create", json!({"type": "paint", "name": format!("Color {}", rng.below(5)), "from": one(&layers, rng)})),
20 => ("style.apply", json!({"ids": [one(&layers, rng)], "style": format!("Color {}", rng.below(5))})),
21 => ("text.set", json!({"id": one(&texts, rng), "characters": format!("Typed {}", rng.below(1000))})),
22 => match rng.below(3) {
0 => ("page.add", json!({"name": format!("Page {}", rng.below(9))})),
// Not the first page: deleting it would take most of the file with it.
1 => ("page.delete", json!({"id": one(doc.pages.get(1..).unwrap_or_default(), rng)})),
_ => ("page.move", json!({"id": one(&doc.pages, rng), "index": rng.below(3)})),
},
23 => ("object.moveToPage", json!({"ids": [one(&layers, rng)], "page": one(&doc.pages, rng)})),
24 => ("comment.add", json!({"x": x, "y": y, "text": "Looks good", "page": one(&doc.pages, rng)})),
25 => match rng.below(3) {
0 => ("variable.createCollection", json!({"name": "Tokens"})),
1 => ("variable.create", json!({"collection": "Tokens", "name": format!("v{}", rng.below(4)), "type": "color", "value": color(rng)})),
_ => ("variable.bind", json!({"ids": [one(&layers, rng)], "field": "fill", "variable": format!("v{}", rng.below(4))})),
},
26 => ("effect.add", json!({"ids": [one(&layers, rng)], "type": "dropShadow"})),
27 => (
"node.set",
json!({"id": one(&layers, rng), "props": {"name": format!("Layer {}", rng.below(50)), "opacity": rng.below(11) as f64 / 10.0}}),
),
28 => ("edit.undo", json!({})),
_ => ("edit.redo", json!({})),
}
}
/// `apply(diff(a, b), a) == b` for every edit of a long random sequence of real commands (undo and
/// redo included), and snapshots rebuild the same state.
#[test]
fn every_command_edit_round_trips_through_its_changes() {
for seed in 0..6 {
let mut rng = Rng::new(seed);
let mut s = Session::with_document(base());
let mut replica = Replica::new(s.doc().unwrap().clone());
let mut edits = 0;
for _ in 0..150 {
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, true);
if s.execute(cmd, &params).is_err() {
continue;
}
let doc = s.doc().unwrap();
let d = diff(&replica, doc).unwrap();
let mut applied = replica.clone();
applied.apply(&d.changes, Mode::Strict).unwrap_or_else(|e| panic!("seed {seed}, {cmd} {params}: {e}\n{:#?}", d.changes));
assert_eq!(applied.document(), doc, "seed {seed}: {cmd} {params}");
assert_eq!(applied, d.replica, "seed {seed}: {cmd} positions");
edits += usize::from(!d.changes.is_empty());
replica = d.replica;
}
assert!(edits > 50, "seed {seed}: only {edits} edits");
let back = Replica::from_snapshot(&replica.snapshot().unwrap()).unwrap();
assert_eq!(back, replica, "seed {seed}: snapshot");
}
}
/// Run `clients` people editing with real commands for `rounds` edits; messages are delayed at
/// random per link. Returns the settled session.
fn session(seed: u64, clients: usize, rounds: usize, recompute: &dyn Recompute) -> Sim {
let mut sim = Sim::new(base(), clients, seed, 10).unwrap();
for _ in 0..rounds {
let i = sim.rng().below(clients);
let doc = sim.document(i).unwrap().clone();
let mut s = Session::with_document(doc);
let mut rng = Rng::new(sim.rng().next_u64());
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false);
if s.execute(cmd, &params).is_ok() {
sim.edit(i, s.doc().unwrap()).unwrap_or_else(|e| panic!("seed {seed}: {cmd} {params}: {e}"));
}
for _ in 0..sim.rng().below(4) {
sim.step(recompute).unwrap();
}
}
sim.settle(recompute, 1_000_000).unwrap();
sim
}
/// The convergence fuzz test: after quiescence every client's document equals the server's.
#[test]
fn people_editing_at_once_converge() {
let (mut rejected, mut repairs) = (0, 0);
for seed in 0..5 {
let sim = session(seed, 3, 60, &EngineRecompute);
if let Err(e) = sim.converged() {
panic!("seed {seed}: {e}");
}
rejected += sim.rejected;
repairs += sim.repairs;
assert!(sim.server.seq() > 20, "seed {seed}: only {} batches accepted", sim.server.seq());
assert!(sim.server.document().validate().is_empty(), "seed {seed}: {:?}", sim.server.document().validate());
}
// Conflicts happened: batches refused (edits to deleted layers, cycles) and derived data
// repaired after concurrent edits.
assert!(rejected > 0 && repairs > 0, "{rejected} rejected, {repairs} repairs");
// Without the recompute hook they converge too (derived data may then be stale).
let sim = session(99, 4, 40, &NoRecompute);
assert_eq!(sim.converged(), Ok(()));
}
#[test]
#[ignore = "longer run for development: cargo test -p figcraft-engine collab -- --ignored"]
fn many_sessions_converge() {
for seed in 100..160 {
let sim = session(seed, 4, 150, &EngineRecompute);
if let Err(e) = sim.converged() {
panic!("seed {seed}: {e}");
}
}
}
#[test]
fn collab_diff_shows_the_last_edit() {
let mut s = Session::with_document(base());
assert!(s.execute("collab.diff", &json!({})).is_err(), "nothing to undo yet");
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let r = s.execute("collab.diff", &json!({})).unwrap();
assert_eq!(r["count"], 1);
assert!(r["changes"][0]["create"]["kind"]["type"] == "rectangle", "{r}");
assert!(r["bytes"].as_u64().unwrap() > 0);
let saved = s.execute("collab.diff", &json!({"against": "saved"})).unwrap();
assert!(saved["count"].as_u64().unwrap() >= 1);
assert!(s.execute("collab.diff", &json!({"against": "nope"})).is_err());
}

View File

@ -13,6 +13,7 @@ pub mod arc_handles;
pub mod catalog;
pub mod cmd;
pub mod codegen;
pub mod collab;
pub mod component_codegen;
pub mod components;
pub mod devcompare;
@ -38,6 +39,7 @@ pub mod tools;
pub mod vectoredit;
pub use cmd::{CommandSpec, all_commands, find_command};
pub use figcraft_collab;
pub use figcraft_doc as doc;
pub use figcraft_svg::looks_like_svg;
pub use tools::{PointerEvent, PointerKind, ToolId};
@ -1065,6 +1067,8 @@ mod autosave_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod collab_tests;
#[cfg(test)]
mod comments_polish_tests;
#[cfg(test)]
mod component_codegen_tests;

View File

@ -1,7 +1,9 @@
# Real-time collaboration protocol (draft)
Status: **proposed design**, milestone M9. Nothing here is implemented yet; this page is the plan
the implementation PRs follow and update.
Status: milestone M9. **Step 1 (the core, no networking) is implemented** in `crates/collab`:
identifiers, the object/property model, change sets, ordering and validation, client
reconciliation, derived data and the message encoding. The rest is the plan the next
implementation PRs follow and update; sections say what is built.
Goal: several people edit the same `.figcraft` file at the same time and see each other's edits,
cursors and selections live, on the desktop app and in the browser, without a central cloud
@ -45,15 +47,27 @@ fit FigCraft's architecture:
## Identifiers
- **Client id**: a 32-bit id the server assigns to each connection on join (never reused within a
file's lifetime; stored with the file).
- **Object ids** must be unique across clients without coordination: `NodeId(u64)` becomes
`client_id << 32 | counter` for objects created in a session (each client counts its own).
Ids in existing files stay valid (they occupy client id 0). The same scheme covers pages, styles,
variables, collections, comments and every other synced object.
*Built.*
- **Client id**: a 32-bit id the server assigns to each connection on join. The server starts
above every client id space already used by node ids in the file, so ids from earlier sessions
are never handed out again (a persistent server will also store its counter with the file).
- **Node ids** are unique across clients without coordination: with a client id set
(`Document::set_client_id`), `Document::alloc_id` returns `client_id << 32 | counter`,
continuing after the highest id of that space already in the document. Ids in existing files
stay valid (they occupy client id 0), and a document without a client id allocates exactly as
before. The counter is saved as the file's `next_id`; the client id is never saved, and neither
is part of document equality.
- An id is never *allocated* twice. A deleted node can come back with its id only by being
created again whole (what undo does): the server accepts creating an id that doesn't exist only
if it was deleted before or lies in the sender's own id space.
- Styles, variables, collections and comments keep their existing string or numeric ids (made
unique by the engine); a client-scoped scheme for them comes with per-user undo.
## The document as objects and properties
*Built* (`figcraft_collab::{Change, Prop, DocProp, Entry, Replica}`).
Every synced thing is an **object** with an id, a type and a set of **properties**:
- Nodes: every field of `Node` that is saved in the file is a property (name, geometry, fills,
@ -68,11 +82,56 @@ Every synced thing is an **object** with an id, a type and a set of **properties
- Document-level data (pages order, styles, variables, collections, modes, published library
metadata, comments, versions) are objects with properties in the same way.
As built:
- **Node properties** are `parent`, `position` and one property per saved field of `Node` (name,
kind with its per-type data, visibility, lock, opacity, blend, transform, size, fills, strokes,
stroke settings, effects, corners, constraints, auto layout, child layout, clip, masks, grids,
guides, exports, interactions, overflow, scroll position, style links, variable bindings and
modes, instance source, overrides, property references, plugin data, handoff status and note,
annotations). `children` is not a property: it follows from the children's `parent` and
`position`. The build fails when `Node` gains a field that isn't listed.
- **Positions** are fractional keys (`figcraft_collab::frac`: base-62 digits after a length-coded
integer part, so appending keeps keys short; deterministic; never ending in `0`). A replica
orders every parent's children, and the pages, by `(key, id)`; equal keys from simultaneous
inserts fall back to id order. Existing documents get keys in their current order on first sync.
A diff gives new keys only to the children that need them (the longest run of old keys that is
still increasing keeps its keys) and re-spaces a parent whose keys would grow past 48 characters.
- **Document-level objects covered now**: the file name, the current page, document colours, the
colour profile, image presets, annotation categories and library data (whole values); styles,
comment threads, variable collections and variables (one entry per id plus the order of ids;
the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content
hash, the encoded bytes as base64). The page order is the pages' positions.
- **Next**: this file's published library (it embeds a snapshot document; it moves with an asset
channel, together with images, which then stop travelling inside batches); the version history
(kept beside the document, not in it) with the standalone server; client-scoped ids for new
styles, variables and comments.
- **Changes** (`Change`): `Create {id, kind, parent, position, props}` (only the properties that
differ from a new node of that kind), `Set {id, prop, value}`, `Delete {id}` (the subtree; a
delete of something already gone does nothing), and for document-level data
`SetDoc {prop, value}`, `Put {entry, value}` and `Remove {entry}`. Values are JSON.
- **Diff** (`figcraft_collab::diff(replica, edited_document)`): only nodes whose `Arc` differs are
visited (the persistent map skips shared structure), so the cost follows the size of the edit. A
batch lists field writes, creations (parents first), moves, deletions (one per subtree, after
the moves out of it), then document-level changes. `Replica::apply` applies a batch strictly
(stop at the first invalid change, then check the result: the server's mode) or leniently (skip
what no longer applies: a client replaying its pending edits). For random edits made by real
engine commands, `apply(diff(a, b), a) == b`, positions included (tested).
## Messages
All messages share an envelope `{type, …}`. Encoding: a compact schema-based binary format
(postcard/serde or protobuf via `prost` derive, decided in the first implementation PR with
benchmarks), with a 1-byte protocol version up front; every message also has a JSON form.
*Built* (`figcraft_collab::message`): the types below and both encodings. The transport and the
presence UI come in step 2.
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
which is compact and needs no schema files. Property values inside are compact JSON text: the
document types use tagged and flattened serde forms that only self-describing formats can read
back, and JSON keeps them exact (floats round-trip). The **JSON form** of every message is the same
types through `serde_json`, externally tagged (`{"changes": {"base_seq": …}}`; postcard can't read
internally tagged enums). Measured: a typical edit (one layer moved: its transform) is **43 bytes
binary vs 139 JSON**; a cursor update is **23 bytes vs 93**. Decoders take a size limit, refuse
trailing bytes and never allocate much beyond the message's size; JSON nesting is capped by the
parser; both are fuzzed.
Client → server:
@ -92,13 +151,20 @@ Server → client:
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
| `Pong` | Keep-alive. |
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
node deletes its subtree; deleted ids are never reused).
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
and `Remove` (see above). A snapshot is the list of changes that builds the document from nothing;
`Welcome` carries one (deltas since a cached `seq` come with reconnect, step 3). A whole batch is
accepted or rejected.
## Ordering, optimism and convergence
*Built* (`figcraft_collab::{Server, Client}`: pure state machines; the transport only moves
messages).
1. A user action runs locally as today (one command or gesture = one undo step). The client diffs
the document before and after, sends the `Changes` batch and shows the result immediately.
2. The client keeps its **unconfirmed batches** in order. Changes from others are applied to the
@ -106,18 +172,50 @@ node deletes its subtree; deleted ids are never reused).
user never sees their own pending edit flicker away.
3. When the server echoes a batch, it moves from unconfirmed to confirmed. On `Reject`, the client
drops the rejected values and applies the server's `fixes`.
4. Result: every client's document equals the server's once everyone has caught up (a randomized
multi-client convergence test is part of the first implementation).
4. Result: every client's document equals the server's once everyone has caught up.
Derived data is **not** synced: auto layout results, component instance contents and variable
resolution are recomputed locally from the synced inputs where possible, so clients don't fight
over computed values. Where FigCraft currently stores derived values in nodes (instance copies,
laid-out positions of auto layout children), the implementation either marks those properties as
derived (recomputed by each client after applying remote changes) or syncs them with the authoring
change; this is decided per property in the first PR and listed here.
As built: the server validates each batch as a whole by applying it to a copy (limits on changes
per batch and layers per file; values of the right type; finite, in-range sizes, transforms and
opacity; known nodes; parents that exist and can hold layers; pages only at the top; no cycles; at
least one page; new ids from the sender's space), then either commits it with the next sequence
number or rejects it with the current values of what it touched as fixes. The client keeps the
confirmed state, applies its own batches at once, and on someone else's batch or a rejection
rebuilds its view as the confirmed state with its pending batches replayed leniently (the fixes
are for clients that keep no confirmed copy). `figcraft_collab::sim` runs a server and N clients
over in-memory links that deliver in order per connection after random delays, through the binary
or the JSON encoding. Tests drive it with synthetic edits and, in the engine, with random real
commands (creating, moving and reparenting layers including cycle attempts, deleting, grouping,
auto layout, components and instances, styles, variables, text, pages, comments): after quiescence
every client's state equals the server's, for every seed (more seeds run as an ignored test).
Undo is not part of these runs (step 3).
### Derived data
*Built.* Decided per property:
- **Synced with the authoring change** (the client making an edit runs the engine's normalize pass
before diffing, so its batch carries consistent values, which receivers apply as they are): auto
layout positions and sizes of children and hugging frames, text box auto-size (it depends on the
fonts installed, so it is computed once, never per machine), group and boolean bounds, instance
contents (copies of their mains with their own ids, overrides respected), values written by
variable bindings and style links, ready-for-dev "changed" flags and snapshots, comment pins
following their layers.
- **Repaired after concurrent edits:** when a client's batch is confirmed after someone else's
batch was ordered before it, that client (only the author) runs the recompute hook over the
nodes its batch touched, on the confirmed state, and sends any difference as a follow-up batch
(leaving out properties its own later pending batches write). The engine's hook is its normalize
pass (`figcraft_engine::collab::EngineRecompute`). Because only the author recomputes, clients
never fight over a value, even with different fonts; a test checks that a derived invariant
broken by concurrent edits is restored.
- **Not synced:** each person's selection, viewport, tool and text caret (presence), the id
counter and client id, and (for now) this file's published library.
## Undo and redo
*Step 3.* Until then the engine's snapshot undo must not be used in a live session: it restores a
whole earlier document and would revert other people's changes made in between. The server already
accepts re-creating deleted nodes with their ids, which undoing a delete needs.
Undo stays per user and undoes only **your own** actions: an undo turns your last batch into a new
batch that writes back the values your batch replaced, for the properties nobody has changed since
(a property someone else changed afterwards is left alone). Redo is the inverse. Remote changes
@ -168,8 +266,10 @@ chooses to share on the network; the default is the invite link with a random to
1. **Core (no networking):** client-scoped ids, the object/property model over `Document`, diff
after a command → `Changes`, apply `Changes`, fractional positions, server-side validation and
ordering as a pure library (`crates/collab`, engine level), and a randomized N-client
convergence test with an in-memory transport.
ordering as a pure library (`crates/collab`, layer 2: it depends on the document model only;
the engine depends on it for the recompute hook and `collab.diff`, which shows the batch an
edit syncs as), and a randomized N-client convergence test with an in-memory transport.
**Done.**
2. **Transport + embedded host:** WebSocket server/client (native), the binary encoding, the
`collab.*` commands, Share / Join UI, live cursors, selections, avatars and follow.
3. **Per-user undo**, reconnect and offline queueing, rejection handling, presence for text

View File

@ -477,6 +477,13 @@ closing or saving a version ends the preview first, and `version.list` marks the
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
### Collaboration changes
`collab.diff {against? (undo: the last undoable action, the default | saved: since the last
save)}` returns `{changes, count, bytes}`: the change batch a live session would send for that edit
(per-property `create` / `set` / `delete` and document-level changes, see
`docs/collab-protocol.md`) and its binary size. It changes nothing.
### Plugins
Sandboxed WebAssembly plugins act through the same commands (see [`plugins.md`](plugins.md) for the

View File

@ -12,6 +12,7 @@ const TABLE: &[(&str, u8)] = &[
("layout", 2),
("text", 2),
("pathops", 2),
("collab", 2),
("plugin", 2),
("render", 3),
("format", 3),
@ -59,6 +60,6 @@ pub fn check(root: &std::path::Path) -> bool {
}
fn same_layer_ok(from: &str, to: &str) -> bool {
// Within L0: color uses geom types; within L2: text/layout/pathops are independent.
// Within L0: color uses geom types; within L2: text/layout/pathops/collab are independent.
matches!((from, to), ("figcraft-color", "figcraft-geom") | ("figcraft-render", "figcraft-format"))
}

View File

@ -62,8 +62,19 @@ fn ci() -> bool {
}
fn wasm() -> bool {
run(cargo().args(["check", "--target", "wasm32-unknown-unknown", "-p", "figcraft-engine", "-p", "figcraft-ui-egui", "-p", "figcraft-web"]))
&& plugins()
run(cargo().args([
"check",
"--target",
"wasm32-unknown-unknown",
"-p",
"figcraft-collab",
"-p",
"figcraft-engine",
"-p",
"figcraft-ui-egui",
"-p",
"figcraft-web",
])) && plugins()
}
/// The example plugins in `plugins/examples` (each folder's name is its package; its module is