Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
430490d
kit: stories and tables inside tree nodes and table cells
itrycodinganditsbad Oct 20, 2026
b1dc1a7
kit tests: typing in one node's story on both machines undoes keystro…
itrycodinganditsbad Oct 20, 2026
5852fb0
kit: an element whose tree node changed is touched (a revived copy af…
itrycodinganditsbad Oct 20, 2026
fbf2747
kit: a change cut into several commits is applied whole by the other …
itrycodinganditsbad Oct 20, 2026
91b775a
live: updates over 1 MiB go in fragments, joined before anything impo…
itrycodinganditsbad Oct 20, 2026
c882e8e
fake agent: changes in fragments, and faults on their way to the apps
itrycodinganditsbad Oct 20, 2026
edec634
kit: one change is one update, and nothing waits for half of one (N1-N6)
itrycodinganditsbad Oct 20, 2026
252be85
kit: a big change goes as a bundle of slices, imported together
itrycodinganditsbad Oct 20, 2026
c34a2be
agent: changes in fragments are stored and relayed as rows, imported …
itrycodinganditsbad Oct 20, 2026
959f46e
kit sim: big changes in fragments, with fragments dropped and held ba…
itrycodinganditsbad Oct 20, 2026
e2d5a54
docs: changes as bundles and fragments, joined before any import
itrycodinganditsbad Oct 20, 2026
92abc34
kit, agent: a bundle whose slices turn slow goes in together
itrycodinganditsbad Oct 20, 2026
b394e75
docs: the slow case of a big paste into a text edited at that moment
itrycodinganditsbad Oct 20, 2026
b7227ac
kit tests: a big change is one update in fragments, one undo step
itrycodinganditsbad Oct 20, 2026
924df23
fragments: changes up to 8 MiB; sets bounded in number (F1, F8)
itrycodinganditsbad Oct 20, 2026
7a7b8ad
server: a change in fragments is stored whole or refused whole; live …
itrycodinganditsbad Oct 20, 2026
2474619
agent: kept fragments end on evidence, never on time (F2, F3, F8)
itrycodinganditsbad Oct 20, 2026
e283631
a change's slices are all read before any goes in (F6)
itrycodinganditsbad Oct 20, 2026
f749d2e
app library: no joining of the agent's changes; a refused fragment re…
itrycodinganditsbad Oct 20, 2026
f50dbb9
local API 2: an older Craft Cloud is told to update, here and on the …
itrycodinganditsbad Oct 20, 2026
e639dc1
agent sim: big changes in fragments, sleeps, snapshots; two bugs it f…
itrycodinganditsbad Oct 20, 2026
d9e93a9
agent: a full document's only device answers the compaction it left f…
itrycodinganditsbad Oct 20, 2026
0873417
docs: changes in fragments as they are now (F1-F9)
itrycodinganditsbad Oct 20, 2026
0041345
agent: toward an older server, nothing goes past a held fragment, and…
itrycodinganditsbad Oct 20, 2026
f7d2d75
server: no fragments from older agents; a set stored in part complete…
itrycodinganditsbad Oct 20, 2026
77bf79b
agent: a fragment's ack is for it alone; a set's rest goes again (G3)
itrycodinganditsbad Oct 20, 2026
9fa3b4c
local API: the agent says its oldest and newest versions; an app take…
itrycodinganditsbad Oct 20, 2026
59e5f96
kit tests: the 5 MB test fails without the bundle (F7, T2)
itrycodinganditsbad Oct 20, 2026
7b3ad40
agent sim: parts of changes kept often, and the guards it missed caug…
itrycodinganditsbad Oct 20, 2026
9cf3252
server: a device refused as full is asked to compact (N-b)
itrycodinganditsbad Oct 20, 2026
93575c1
docs: older agents and servers, sets completed from the log, check-se…
itrycodinganditsbad Oct 20, 2026
f2c27f5
app library: the agent's versions asked once per client, not at every…
itrycodinganditsbad Oct 20, 2026
48e7de3
server, agent: a set whose found part is below the snapshot's base st…
itrycodinganditsbad Oct 20, 2026
fef6cd3
server: check-sets groups by set and count (N-f)
itrycodinganditsbad Oct 20, 2026
892ee0f
docs: an older live-editing app on a new agent can't read bundles (H2)
itrycodinganditsbad Oct 20, 2026
3e79dd6
agent: a second set refused for a start over doesn't start over again…
itrycodinganditsbad Oct 20, 2026
5a2ff37
app library: nothing kept of a change in fragments once it is answere…
itrycodinganditsbad Oct 21, 2026
9daaa3b
server: a set's stored fragments found in one query
itrycodinganditsbad Oct 21, 2026
659e74e
docs: the change writer, the status's api_max, a doc comment back on …
itrycodinganditsbad Oct 21, 2026
68c0415
kit: a change that doesn't read is said and counted, at open too
itrycodinganditsbad Oct 21, 2026
ab4f903
notes: stale docs, an unused field, the writer's leftover estimate, o…
itrycodinganditsbad Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ crates/
store craft-cloud-store blob store: disk, S3, memory; checks every blob in and out
meta craft-cloud-meta metadata on SQLite or Postgres; the batch commit; the change feed; invites and pairings (`join.rs`); live documents' logs and snapshots (`live.rs`)
sync craft-cloud-sync the sync engine over `Fs`/`Transport` traits; `MemFs`; scenario tests and the simulation
kit craft-cloud-kit the app kit (`docs/kit.md`): an app's model live through the agent; the `LiveDocument` trait and `Session` (`session.rs`: open, the `local_change` hook, `poll`, undo through Loro's undo manager, reconnect and the 64 MiB queue, presence, saves), the change writer that cuts commits under 512 KiB (`writer.rs`), the mappings (`tree.rs`, `text.rs`, `table.rs`, `timeline.rs`, `opaque.rs`, `list.rs`), values, chunk references and blob stores (`value.rs`)
kit craft-cloud-kit the app kit (`docs/kit.md`): an app's model live through the agent; the `LiveDocument` trait and `Session` (`session.rs`: open, the `local_change` hook, `poll`, undo through Loro's undo manager, reconnect and the 64 MiB queue, presence, saves), the change writer: one commit per change, exported as one update (a bundle of `SLICE_OPS` slices), cut in fragments past 1 MiB by `craft-cloud-app` (`writer.rs`), the mappings (`tree.rs`, `text.rs`, `table.rs`, `timeline.rs`, `opaque.rs`, `list.rs`; stories and tables nest in a node's or a cell's map: `props.rs`), values, chunk references and blob stores (`value.rs`)
egui craft-cloud-egui Craft Cloud's piece of an app's window: the account line, the shared recents, a live document's badge, and `CloudWatch` that keeps them current
app craft-cloud-app what apps link: a blocking client of the agent's local API (status, sign-in, joining, computers, pause, autostart, recents, live documents at the level of bytes: `live.rs`)
apps/
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions apps/craft-cloud-agent/src/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,21 @@ fn session(
};
let mirror = LoroDoc::new();
mirror.import(document.state()).map_err(|e| usage(format!("the document's state doesn't read: {e}")))?;
// (An update this copy can't take is the other side's; the agent has it, and so will a fresh open. A bundle's
// slices go in one after the other.)
let import = |mirror: &LoroDoc, bytes: &[u8]| {
for part in craft_cloud_proto::fragments::parts(bytes).unwrap_or_default() {
let _ = mirror.import(part);
}
};
for update in document.state_updates() {
// (An update this copy can't take is the other side's; the agent has it, and so will a fresh open.)
let _ = mirror.import(update);
import(&mirror, update);
}
let text = |mirror: &LoroDoc| mirror.get_text(TEXT).to_string();
// What came right after the state (updates still waiting for their history), before anything else.
let take = |mirror: &LoroDoc, event: &Event| {
if let Event::Update(bytes) = event {
// (An update this copy can't take is the other side's; the agent has it, and so will a fresh open.)
let _ = mirror.import(bytes);
import(mirror, bytes);
}
};
while let Some(event) = document.try_recv() {
Expand Down Expand Up @@ -148,7 +153,7 @@ fn session(
loop {
match document.recv_timeout(Duration::from_secs(1)) {
Some(Event::Update(bytes)) => {
let _ = mirror.import(&bytes);
import(&mirror, &bytes);
say("--".into());
say(text(&mirror));
}
Expand Down
173 changes: 168 additions & 5 deletions apps/craft-cloud-agent/src/live/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use std::collections::BTreeMap;

use craft_cloud_proto::fragments::{is_bundle, parts};
use craft_cloud_proto::live::MAX_UPDATE_BYTES;
use loro::{ExportMode, IdSpan, LoroDoc, VersionVector};

Expand Down Expand Up @@ -44,9 +45,16 @@ pub fn frontiers(ids: &[(u64, u32)]) -> Option<loro::Frontiers> {
ids.iter().map(|(peer, counter)| Some(loro::ID::new(*peer, i32::try_from(*counter).ok()?))).collect::<Option<Vec<_>>>().map(loro::Frontiers::from)
}

/// The ranges of operations an update (or snapshot) holds, read from its header without importing it; `None` for
/// bytes Loro can't read.
/// The ranges of operations an update (or snapshot, or a bundle of updates: its slices', merged) holds, read from its
/// header without importing it; `None` for bytes Loro can't read.
pub fn spans_of(bytes: &[u8]) -> Option<Vec<(u64, i32, i32)>> {
if is_bundle(bytes) {
let mut spans = Spans::new();
for part in parts(bytes)? {
add_spans(&mut spans, &spans_of(part)?);
}
return Some(spans.into_iter().flat_map(|(peer, list)| list.into_iter().map(move |(s, e)| (peer, s, e))).collect());
}
let meta = guarded(|| LoroDoc::decode_import_blob_meta(bytes, true).map_err(|e| UpdateError::Malformed(e.to_string()))).ok()?;
Some(meta.partial_end_vv.iter().map(|(peer, end)| (*peer, meta.partial_start_vv.get(peer).copied().unwrap_or(0), *end)).collect())
}
Expand Down Expand Up @@ -94,6 +102,10 @@ fn usable_peer(peer: u64) -> u64 {

/// The most bytes of updates one of Loro's batch imports takes ([`LiveCopy::import_batch`]).
const BATCH_GROUP_BYTES: usize = 16 * 1024;
/// A bundle's slice whose import takes this long: the rest goes in as one update ([`LiveCopy::import_any_size`]).
/// (The kit takes slices the same way, with the same threshold: `import_slices` in `craft-cloud-kit`; keep the two
/// alike.)
const SLOW_SLICE: std::time::Duration = std::time::Duration::from_millis(50);

/// `updates` in consecutive groups of at most `max` bytes (an update bigger than that alone).
fn groups(updates: &[Vec<u8>], max: usize) -> Vec<&[Vec<u8>]> {
Expand Down Expand Up @@ -188,8 +200,57 @@ impl LiveCopy {
self.import_any_size(bytes)
}

/// [`LiveCopy::import`] without the update limit: for a snapshot, or this machine's own stored document.
/// [`LiveCopy::import`] without the update limit: for a snapshot, or this machine's own stored document. A bundle
/// (one change as its slices) goes in slice by slice, all of it before this returns; one that doesn't read is
/// refused whole.
pub fn import_any_size(&self, bytes: &[u8]) -> Result<Taken, UpdateError> {
if is_bundle(bytes) {
let slices = parts(bytes).ok_or_else(|| UpdateError::Malformed("a bundle cut short".into()))?;
// Every slice is read before any goes in: one that doesn't read refuses the bundle whole, nothing of it in.
for slice in &slices {
guarded(|| LoroDoc::decode_import_blob_meta(slice, true).map_err(|e| UpdateError::Malformed(e.to_string())))?;
}
let (mut new, mut needs) = (false, Vec::new());
let mut take = |taken: Taken| match taken {
Taken::Applied { new: n } => new |= n,
Taken::Waiting { needs: more } => needs.extend(more),
};
let mut i = 0;
while let Some(slice) = slices.get(i) {
let started = std::time::Instant::now();
take(self.import_one(slice)?);
i += 1;
// A slice this slow: Loro takes its slow way (edits in the same text concurrent with the change, in
// one order of the peers, make each slice cost more than the last): the rest goes in as one update.
let rest = slices.get(i..).unwrap_or_default();
if rest.len() >= 2
&& started.elapsed() > SLOW_SLICE
&& let Ok(one) = self.joined(rest)
{
take(self.import_one(&one)?);
break;
}
}
return Ok(if needs.is_empty() { Taken::Applied { new } } else { Taken::Waiting { needs } });
}
self.import_one(bytes)
}

/// `updates` (which follow on from this copy) as one update, joined on a detached fork (which only takes their
/// operations).
fn joined(&self, updates: &[&[u8]]) -> Result<Vec<u8>, UpdateError> {
self.guard(|doc| {
let fork = doc.fork();
fork.detach();
let before = fork.oplog_vv();
for u in updates {
fork.import(u).map_err(|e| UpdateError::Malformed(e.to_string()))?;
}
fork.export(ExportMode::updates(&before)).map_err(|e| UpdateError::Malformed(e.to_string()))
})
}

fn import_one(&self, bytes: &[u8]) -> Result<Taken, UpdateError> {
let status = self.guard(|doc| {
#[cfg(test)]
Self::failing(&[bytes]);
Expand Down Expand Up @@ -340,6 +401,9 @@ impl LiveCopy {

/// The longest start of `changes` that fits in `max` bytes as one update (at least one change; `Invalid` if that
/// one doesn't fit in `hard_max`), with how many changes it holds.
///
/// (A Loro change is a run of one peer's commits Loro merged, not an app's change, so a big one goes in pieces
/// here: this is history the log lost, sent up again after a server's restore.)
pub fn export_prefix(&self, changes: &[(u64, i32, i32)], max: usize, hard_max: usize) -> Result<(Vec<u8>, usize), UpdateError> {
let Some(&(peer, start, end)) = changes.first() else { return Err(UpdateError::Invalid("nothing to send")) };
// The most operations of the first change that fit, by doubling from one and then halving the gap, so no
Expand Down Expand Up @@ -425,11 +489,17 @@ impl LiveCopy {
/// after a catch-up from the start, or worked out from the copy when nothing else is in it); every update taken after
/// it adds its ranges. A version with a gap (an update whose history comes later in the log) is no version at all:
/// no snapshot can be made there.
///
/// A change that came in fragments is in the log from its last fragment's row on (its ranges are noted there), and
/// no version is offered between its first fragment's row and its last: a snapshot there would drop its first
/// fragments from the log and keep the rest, and a machine catching up later would never get it whole.
#[derive(Debug, Clone, Default)]
pub struct LogVersion {
anchor: Option<(u64, Version)>,
/// The updates taken since the anchor, in order: number and ranges.
since: Vec<(u64, Ranges)>,
/// The rows of the changes that came in fragments since the anchor: first and last.
sets: Vec<(u64, u64)>,
}

/// The ranges of operations of an update: peer, first and end counter.
Expand All @@ -441,7 +511,14 @@ const MAX_SINCE: usize = 100_000;
impl LogVersion {
/// Anchored at `seq` with `version`.
pub fn anchored(seq: u64, version: Version) -> Self {
Self { anchor: Some((seq, version)), since: Vec::new() }
Self { anchor: Some((seq, version)), since: Vec::new(), sets: Vec::new() }
}

/// A change that came in fragments took the rows `first` to `last`.
pub fn note_set(&mut self, first: u64, last: u64) {
if self.anchor.is_some() && last > first {
self.sets.push((first, last));
}
}

/// Nothing known.
Expand Down Expand Up @@ -469,7 +546,7 @@ impl LogVersion {
/// The version the log's updates up to `upto` make, if it is known and has no gap.
pub fn at(&self, upto: u64) -> Option<Version> {
let (at, base) = self.anchor.as_ref()?;
if upto < *at {
if upto < *at || self.sets.iter().any(|(first, last)| *first <= upto && upto < *last) {
return None;
}
let mut spans = Spans::new();
Expand All @@ -495,6 +572,7 @@ impl LogVersion {
/// The version at `upto` is `version` (a snapshot was made there): what came before is forgotten.
pub fn rebase(&mut self, upto: u64, version: Version) {
self.since.retain(|(seq, _)| *seq > upto);
self.sets.retain(|(_, last)| *last > upto);
self.anchor = Some((upto, version));
}
}
Expand Down Expand Up @@ -579,6 +657,91 @@ mod tests {
assert_eq!(back.get_text("text").len_utf8(), 5 * 500 * 1024, "all of it");
}

/// A 4 MiB paste on top of another peer's text: whole in one update, Loro's import takes seconds (time growing
/// with the square of the text); as a bundle of slices (as the kit sends it), the copy takes it in a moment, and
/// holds all of it. A bundle's ranges are its slices'.
#[test]
fn a_big_change_as_a_bundle_of_slices_imports_at_once() {
use craft_cloud_proto::fragments::{SLICE_OPS, bundle};
let importer = LoroDoc::new();
importer.set_peer_id(40).unwrap();
text_update(&importer, 0, "import");
let import = importer.export(ExportMode::Snapshot).unwrap();
let app = LoroDoc::new();
app.set_peer_id(50).unwrap();
app.import(&import).unwrap();
app.get_text("text").insert(0, &"a".repeat(4 * 1024 * 1024)).unwrap();
app.commit();
let end = *app.oplog_vv().get(&50).unwrap();
let mut slices = Vec::new();
let mut at = 0;
while at < end {
let to = (at + SLICE_OPS).min(end);
slices.push(app.export(ExportMode::updates_in_range(vec![IdSpan::new(50, at, to)])).unwrap());
at = to;
}
let bundle_bytes = bundle(&slices);
assert!(is_bundle(&bundle_bytes));
assert_eq!(spans_of(&bundle_bytes), Some(vec![(50, 0, end)]), "its ranges, merged");
let copy = LiveCopy::load(&import, 1).unwrap();
let started = std::time::Instant::now();
assert_eq!(copy.import_any_size(&bundle_bytes).unwrap(), Taken::Applied { new: true });
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed());
assert!(copy.covers(&[(50, end)]));
let back = LoroDoc::new();
back.import(&copy.snapshot().unwrap()).unwrap();
assert_eq!(back.get_text("text").len_utf8(), 4 * 1024 * 1024 + "import".len());
// A snapshot of the copy as it is: at once too.
let started = std::time::Instant::now();
let at_now = copy.snapshot_at(&copy.version().unwrap()).unwrap();
assert!(!at_now.is_empty());
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed());
// A bundle with a slice that doesn't read is refused whole: nothing of its other slices goes in.
let fresh = LiveCopy::load(&import, 1).unwrap();
let mut bad: Vec<Vec<u8>> = slices.iter().take(3).cloned().collect();
bad.push(b"not an update".to_vec());
assert!(fresh.import_any_size(&bundle(&bad)).is_err());
assert!(!fresh.covers(&[(50, 1)]), "nothing of it in");
}

/// A 600 KB paste's bundle into a copy holding an app's edit of the same text made at the same moment, the edit's
/// peer below the paste's (the order Loro is slow in, slice after slice: about a minute here): a few slices one
/// by one, then the rest together, in seconds, and the copy holds both.
#[test]
fn a_bundle_into_a_text_edited_at_the_same_moment_goes_in_in_seconds() {
use craft_cloud_proto::fragments::{SLICE_OPS, bundle};
let importer = LoroDoc::new();
importer.set_peer_id(40).unwrap();
text_update(&importer, 0, "import");
let import = importer.export(ExportMode::Snapshot).unwrap();
let typist = LoroDoc::new();
typist.set_peer_id(10).unwrap();
typist.import(&import).unwrap();
let typed = text_update(&typist, 0, "hello ");
let paster = LoroDoc::new();
paster.set_peer_id(50).unwrap();
paster.import(&import).unwrap();
let paste = "p".repeat(600 * 1024);
paster.get_text("text").insert(6, &paste).unwrap();
paster.commit();
let end = *paster.oplog_vv().get(&50).unwrap();
let mut slices = Vec::new();
let mut at = 0;
while at < end {
let to = (at + SLICE_OPS).min(end);
slices.push(paster.export(ExportMode::updates_in_range(vec![IdSpan::new(50, at, to)])).unwrap());
at = to;
}
let copy = LiveCopy::load(&import, 1).unwrap();
copy.import(&typed).unwrap();
let started = std::time::Instant::now();
assert_eq!(copy.import_any_size(&bundle(&slices)).unwrap(), Taken::Applied { new: true });
assert!(started.elapsed() < std::time::Duration::from_secs(15), "{:?}", started.elapsed());
let back = LoroDoc::new();
back.import(&copy.snapshot().unwrap()).unwrap();
assert_eq!(back.get_text("text").to_string(), format!("hello import{paste}"));
}

/// An update Loro refuses leaves the copy as it was (Ruling: validation without a scratch copy).
#[test]
fn a_refused_update_leaves_the_copy_as_it_was() {
Expand Down
Loading
Loading