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:
parent
4dd54d19f0
commit
4924512f4e
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
19
ROADMAP.md
19
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 |
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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)])
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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() },
|
||||
}))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -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, ¶ms).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, ¶ms).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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue