From 4924512f4e83b7b9b5a89a7145d4fe1ffe230022 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 01:03:03 +0700 Subject: [PATCH] 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 * 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 * M9: fmt after merge --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 49 +++++ Cargo.toml | 2 + ROADMAP.md | 19 +- crates/collab/Cargo.toml | 22 ++ crates/collab/src/change.rs | 236 ++++++++++++++++++++ crates/collab/src/client.rs | 244 +++++++++++++++++++++ crates/collab/src/codec_tests.rs | 160 ++++++++++++++ crates/collab/src/diff.rs | 198 +++++++++++++++++ crates/collab/src/diff_tests.rs | 246 +++++++++++++++++++++ crates/collab/src/docprops.rs | 340 ++++++++++++++++++++++++++++ crates/collab/src/frac.rs | 353 ++++++++++++++++++++++++++++++ crates/collab/src/frac_tests.rs | 104 +++++++++ crates/collab/src/lib.rs | 101 +++++++++ crates/collab/src/message.rs | 152 +++++++++++++ crates/collab/src/replica.rs | 314 ++++++++++++++++++++++++++ crates/collab/src/server.rs | 246 +++++++++++++++++++++ crates/collab/src/server_tests.rs | 302 +++++++++++++++++++++++++ crates/collab/src/sim.rs | 273 +++++++++++++++++++++++ crates/collab/src/sim_tests.rs | 166 ++++++++++++++ crates/doc/src/client_id_tests.rs | 127 +++++++++++ crates/doc/src/lib.rs | 163 ++++++++++++-- crates/doc/src/library.rs | 5 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/collab.rs | 49 +++++ crates/engine/src/collab_tests.rs | 188 ++++++++++++++++ crates/engine/src/lib.rs | 4 + docs/collab-protocol.md | 144 ++++++++++-- docs/mcp.md | 7 + xtask/src/layers.rs | 3 +- xtask/src/main.rs | 15 +- 31 files changed, 4179 insertions(+), 55 deletions(-) create mode 100644 crates/collab/Cargo.toml create mode 100644 crates/collab/src/change.rs create mode 100644 crates/collab/src/client.rs create mode 100644 crates/collab/src/codec_tests.rs create mode 100644 crates/collab/src/diff.rs create mode 100644 crates/collab/src/diff_tests.rs create mode 100644 crates/collab/src/docprops.rs create mode 100644 crates/collab/src/frac.rs create mode 100644 crates/collab/src/frac_tests.rs create mode 100644 crates/collab/src/lib.rs create mode 100644 crates/collab/src/message.rs create mode 100644 crates/collab/src/replica.rs create mode 100644 crates/collab/src/server.rs create mode 100644 crates/collab/src/server_tests.rs create mode 100644 crates/collab/src/sim.rs create mode 100644 crates/collab/src/sim_tests.rs create mode 100644 crates/doc/src/client_id_tests.rs create mode 100644 crates/engine/src/collab.rs create mode 100644 crates/engine/src/collab_tests.rs diff --git a/Cargo.lock b/Cargo.lock index dcfe859..1f20c32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 9f83632..64ea7d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index 040acbd..cd62821 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml new file mode 100644 index 0000000..d272617 --- /dev/null +++ b/crates/collab/Cargo.toml @@ -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 } diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs new file mode 100644 index 0000000..f0027c4 --- /dev/null +++ b/crates/collab/src/change.rs @@ -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(v: &T) -> Result { + 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::deserialize(&self.0).map_err(|e| Error::Invalid(format!("{what}: {e}"))) + } +} + +impl Serialize for Json { + fn serialize(&self, s: S) -> Result { + 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: D) -> Result { + 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(self, v: &str) -> Result { + // 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, 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 { + 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 { + 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), +} diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs new file mode 100644 index 0000000..1b14ae7 --- /dev/null +++ b/crates/collab/src/client.rs @@ -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, + /// 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, + /// `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, + presence: BTreeMap, +} + +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 { + 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 { + 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, 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) -> 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, 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, recompute: &dyn Recompute) -> Result, 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 = + 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)]) + } +} diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs new file mode 100644 index 0000000..c2cb516 --- /dev/null +++ b/crates/collab/src/codec_tests.rs @@ -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, Vec) { + 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::(&encode(m).unwrap(), usize::MAX).unwrap(), m); + assert_eq!(&decode_json::(&encode_json(m).unwrap(), usize::MAX).unwrap(), m); + } + for m in &server { + assert_eq!(&decode::(&encode(m).unwrap(), usize::MAX).unwrap(), m); + assert_eq!(&decode_json::(&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::(&[], 100), Err(Error::Decode(_)))); + assert!(matches!(decode::(&[WIRE_VERSION + 1, 4], 100), Err(Error::Decode(_)))); + let ping = encode(&ClientMsg::Ping).unwrap(); + let mut trailing = ping.clone(); + trailing.push(0); + assert!(decode::(&trailing, 100).is_err(), "trailing bytes"); + assert!(decode::(&ping, 1).is_err(), "over the size limit"); + assert!(decode_json::("{\"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::(&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::(&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::(&bin, usize::MAX), Err(Error::Decode(_)))); +} + +#[test] +fn fuzzed_messages_never_panic() { + let (client, server) = samples(); + let valid: Vec> = 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::(&bytes, 1 << 20); + let _ = decode::(&bytes, 1 << 20); + if let Ok(text) = std::str::from_utf8(&bytes) { + let _ = decode_json::(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}"); +} diff --git a/crates/collab/src/diff.rs b/crates/collab/src/diff.rs new file mode 100644 index 0000000..016dc5d --- /dev/null +++ b/crates/collab/src/diff.rs @@ -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, + /// 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 { + 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> = BTreeSet::new(); + let mut moved: BTreeSet = 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 = 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 = 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, Error> { + let olds: Vec> = 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, 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 { + // Patience sorting: `tails[l]` is the index ending the best run of length l + 1. + let mut tails: Vec = Vec::new(); + let mut prev: Vec> = 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 +} diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs new file mode 100644 index 0000000..0bda561 --- /dev/null +++ b/crates/collab/src/diff_tests.rs @@ -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 { + 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 = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap(); + assert_eq!(wire, d.changes, "binary round trip"); + let json: Vec = 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) { + 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 = 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 = (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()); +} diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs new file mode 100644 index 0000000..3483cae --- /dev/null +++ b/crates/collab/src/docprops.rs @@ -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 { + whole: DocProp, + order: DocProp, + entry: fn(T::Key) -> Entry, + get: fn(&Document) -> &Vec, + get_mut: fn(&mut Document) -> &mut Vec, +} + +const STYLES: List