Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ so this log can be regenerated from history (e.g. with `git-cliff`).

## [Unreleased]

### Added
- Page byline: "Created by … · date" under the title, plus the people who have
changed the page's content (typing, imports, restores, task toggles), each
with when they last edited. Visible to every role that can open the page;
names only. Pages created before this release list only contributions made
from now on, and say so. `GET /api/docs/{id}/contributors`.

### Fixed
- **Edits typed while disconnected never reached the server.** The client kept
them locally but only sent changes while its socket was open, and the server
never asked a reconnecting client for what it was missing. They were lost on
reload, and later edits building on them were parked by the server as
pending, invisible to everyone else. The server now sends its state vector
on join (y-protocol SyncStep1), so the client uploads what the server lacks.
Already-loaded clients benefit without a reload. Boards had the same gap and
get the same fix.
- Live edits were stored without an author, and a writer batch was stamped
with its first row's author. Every `doc_updates` row now records who made it;
history restore and workspace import are attributed too.
- An open editor no longer autolinks text that arrives from someone else's
change, which wrote a link edit into the shared document under the
observer's name.
- **Revoking access did not reach open editors.** The 4403 close an ACL change
should send never fired, so a revoked user kept a working socket and their
edits were still applied and saved. Open connections are now closed with
4403 and anything they send after the revoke is dropped. The same defect
leaked a task and a socket on the server for every disconnect, until
restart.

## [0.7.0] - 2026-10-02

### Added
Expand Down
21 changes: 14 additions & 7 deletions crates/knot-crdt/src/board_room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::bus::Bus;
use crate::engine::{DocHandle, Engine, EngineError};

use crate::protocol::wrap_sync_update;
pub use crate::room::JoinState;

pub type ConnId = Uuid;

Expand All @@ -37,7 +38,7 @@ pub enum Event {
Join {
conn_id: ConnId,
handle: ConnHandle,
reply: oneshot::Sender<Result<Vec<u8>, EngineError>>,
reply: oneshot::Sender<Result<JoinState, EngineError>>,
},
Leave(ConnId),
AwarenessIn {
Expand Down Expand Up @@ -219,18 +220,24 @@ impl BoardRoom {
&mut self,
conn_id: ConnId,
handle: ConnHandle,
reply: oneshot::Sender<Result<Vec<u8>, EngineError>>,
reply: oneshot::Sender<Result<JoinState, EngineError>>,
) {
self.conns.insert(conn_id, handle);
let r = self.engine.encode_state_as_update(&self.doc, None);
let _ = reply.send(r);
let _ = reply.send(JoinState::of(self.engine.as_ref(), &self.doc));
}

#[tracing::instrument(skip(self, m), fields(board_id = %self.board_id, bytes = m.bytes.len()))]
async fn on_inbound(&mut self, m: InMsg) {
if let Err(e) = self.engine.apply_update(&self.doc, &m.bytes) {
tracing::debug!(error=?e, "apply_update failed");
return;
match self.engine.apply_update_changed(&self.doc, &m.bytes) {
Ok(true) => {}
// Nothing new — the usual answer to the server's SyncStep1 from a
// (re)connecting client. Don't append a `board_updates` row per
// open or echo the no-op to every peer (see `Room::on_inbound`).
Ok(false) => return,
Err(e) => {
tracing::debug!(error=?e, "apply_update failed");
return;
}
}
// Persist inline and capture the seq so we can advance the watermark
// before publishing — ensures the self-NOTIFY from our own publish
Expand Down
211 changes: 211 additions & 0 deletions crates/knot-crdt/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ impl DocHandle {
pub trait Engine: Send + Sync + 'static {
fn new_doc(&self) -> DocHandle;
fn apply_update(&self, d: &DocHandle, update: &[u8]) -> Result<(), EngineError>;
/// Apply `update` and report whether the document changed: new content
/// integrated, something deleted, or content parked as pending because a
/// dependency is missing. `Ok(false)` means the update carried nothing the
/// doc did not already have — e.g. the SyncStep2 a reconnecting client
/// sends back, which always includes its full delete set. Callers use it
/// to skip persisting / fanning out such no-ops; anything that could not
/// be integrated yet reports `true`, so it is never dropped.
fn apply_update_changed(&self, d: &DocHandle, update: &[u8]) -> Result<bool, EngineError>;
fn encode_state_as_update(
&self,
d: &DocHandle,
Expand All @@ -54,6 +62,27 @@ impl Engine for YrsEngine {
Ok(())
}

fn apply_update_changed(&self, d: &DocHandle, update: &[u8]) -> Result<bool, EngineError> {
let u = Update::decode_v1(update).map_err(|e| EngineError::Apply(e.to_string()))?;
let mut txn = d.0.transact_mut();
let pending_before = pending_fingerprint(&txn);
txn.apply_update(u)
.map_err(|e| EngineError::Apply(e.to_string()))?;
// - insert_set: structs THIS transaction integrated (yrs records every
// integrated item and GC range there), so already-known structs in
// a re-sent state do not count;
// - delete_set: likewise only items newly deleted here, so the full
// delete set every SyncStep2 carries does not count by itself;
// - pending: structs/deletes yrs had to park because a dependency is
// missing. Compared before/after, not as a doc-wide flag: a doc can
// hold pending content indefinitely (and snapshots and every
// client's state re-send it), and re-delivering it changes nothing.
// Content that is newly parked does change it, and is never dropped.
Ok(!txn.insert_set().is_empty()
|| !txn.delete_set().is_empty()
|| pending_fingerprint(&txn) != pending_before)
}

fn encode_state_as_update(
&self,
d: &DocHandle,
Expand All @@ -75,6 +104,19 @@ impl Engine for YrsEngine {
}
}

/// The doc's pending (not yet integrable) structs and deletes, encoded, so a
/// caller can tell whether a transaction parked anything new. `None` in the
/// common case of nothing pending, which costs nothing to compute.
fn pending_fingerprint<T: ReadTxn>(txn: &T) -> Option<(Vec<u8>, Vec<u8>)> {
let store = txn.store();
let structs = store.pending_update().map(|p| p.update.encode_v1());
let deletes = store.pending_ds().map(|ds| ds.encode_v1());
if structs.is_none() && deletes.is_none() {
return None;
}
Some((structs.unwrap_or_default(), deletes.unwrap_or_default()))
}

#[derive(Debug, Clone)]
pub struct TextMark {
pub kind: String,
Expand All @@ -86,3 +128,172 @@ pub struct TextMarkAttr {
pub name: String,
pub value: String,
}

#[cfg(test)]
mod tests {
use super::*;
use yrs::{GetString, Text};

/// Encode everything `doc` holds as a v1 update.
fn full_update(doc: &Doc) -> Vec<u8> {
doc.transact()
.encode_state_as_update_v1(&StateVector::default())
}

#[test]
fn apply_update_changed_reports_new_content() {
let src = Doc::new();
src.get_or_insert_text("t")
.push(&mut src.transact_mut(), "hello");

let e = YrsEngine;
let d = e.new_doc();
assert!(e.apply_update_changed(&d, &full_update(&src)).unwrap());
}

/// The reconnect handshake makes every writer reply with a SyncStep2
/// that usually re-sends what the server already has (plus the whole
/// delete set). That must read as "unchanged".
#[test]
fn reapplying_known_content_including_deletes_is_unchanged() {
let src = Doc::new();
let t = src.get_or_insert_text("t");
t.push(&mut src.transact_mut(), "hellox");
t.remove_range(&mut src.transact_mut(), 5, 1);
let update = full_update(&src);

let e = YrsEngine;
let d = e.new_doc();
assert!(e.apply_update_changed(&d, &update).unwrap());
assert!(
!e.apply_update_changed(&d, &update).unwrap(),
"re-applying an identical update (with a delete set) must be a no-op"
);
// And a diff against our own state vector — exactly what a client
// answers to the server's SyncStep1 — is a no-op too.
let sv = e.encode_state_vector(&d).unwrap();
let reply = {
let sv = StateVector::decode_v1(&sv).unwrap();
src.transact().encode_state_as_update_v1(&sv)
};
assert!(!e.apply_update_changed(&d, &reply).unwrap());
}

/// A deletion adds no new structs (the state vector does not move) but
/// it does change the document.
#[test]
fn delete_only_update_is_changed() {
let src = Doc::new();
let t = src.get_or_insert_text("t");
t.push(&mut src.transact_mut(), "hello");

let e = YrsEngine;
let d = e.new_doc();
e.apply_update(&d, &full_update(&src)).unwrap();

let before = src.transact().state_vector();
t.remove_range(&mut src.transact_mut(), 0, 1);
let delete_only = src.transact().encode_state_as_update_v1(&before);
assert_eq!(
src.transact().state_vector(),
before,
"fixture: a delete must not advance the state vector"
);

assert!(e.apply_update_changed(&d, &delete_only).unwrap());
let txt = d.0.get_or_insert_text("t");
assert_eq!(txt.get_string(&d.0.transact()), "ello");
}

/// An update whose dependency the server lacks cannot be integrated yet;
/// yrs parks it as pending. It must still count as a change so the room
/// persists it — dropping it would lose the edit for good once the
/// missing piece arrives later.
#[test]
fn update_with_missing_dependency_is_changed() {
let src = Doc::new();
let t = src.get_or_insert_text("t");
t.push(&mut src.transact_mut(), "a");
let sv_after_a = src.transact().state_vector();
t.push(&mut src.transact_mut(), "b");
let only_b = src.transact().encode_state_as_update_v1(&sv_after_a);

let e = YrsEngine;
let d = e.new_doc();
assert!(
e.apply_update_changed(&d, &only_b).unwrap(),
"an update parked as pending must be reported as changed"
);
assert!(d.0.transact().has_missing_updates());
assert_eq!(
e.encode_state_vector(&d).unwrap(),
StateVector::default().encode_v1(),
"fixture: the dependent update must not have integrated"
);
}

/// A doc can hold pending content for good: the offline-edit bug left
/// docs whose later edits depend on clocks that were never uploaded, and
/// snapshots keep the pending part (yrs and Yjs both re-encode it into
/// every state update). Then every client's SyncStep2 reply carries that
/// pending content again. Re-delivering what is already pending changes
/// nothing and must not read as a change — or every page open on such a
/// doc would be persisted and credited as an edit.
#[test]
fn redelivering_already_pending_content_is_unchanged() {
let src = Doc::new();
let t = src.get_or_insert_text("t");
t.push(&mut src.transact_mut(), "a");
let sv_after_a = src.transact().state_vector();
t.push(&mut src.transact_mut(), "b");
let only_b = src.transact().encode_state_as_update_v1(&sv_after_a);

let e = YrsEngine;
let d = e.new_doc();
assert!(e.apply_update_changed(&d, &only_b).unwrap());
assert!(
d.0.transact().has_missing_updates(),
"fixture: b must be pending"
);

assert!(
!e.apply_update_changed(&d, &only_b).unwrap(),
"re-delivering the pending update must be a no-op"
);
// What a client that loaded this doc answers to SyncStep1: the doc's
// state relative to the server's (empty) state vector, which includes
// the pending content.
let reply = full_update(&d.0);
assert!(
!e.apply_update_changed(&d, &reply).unwrap(),
"a SyncStep2 reply that only re-sends pending content must be a no-op"
);
}

/// The counterpart: filling the gap integrates the pending content.
#[test]
fn filling_the_missing_dependency_is_changed() {
let src = Doc::new();
let t = src.get_or_insert_text("t");
t.push(&mut src.transact_mut(), "a");
let only_a = full_update(&src);
let sv_after_a = src.transact().state_vector();
t.push(&mut src.transact_mut(), "b");
let only_b = src.transact().encode_state_as_update_v1(&sv_after_a);

let e = YrsEngine;
let d = e.new_doc();
e.apply_update(&d, &only_b).unwrap();
assert!(e.apply_update_changed(&d, &only_a).unwrap());
assert!(!d.0.transact().has_missing_updates());
let txt = d.0.get_or_insert_text("t");
assert_eq!(txt.get_string(&d.0.transact()), "ab");
}

#[test]
fn malformed_update_is_an_error() {
let e = YrsEngine;
let d = e.new_doc();
assert!(e.apply_update_changed(&d, &[0xff, 0xff, 0xff]).is_err());
}
}
2 changes: 1 addition & 1 deletion crates/knot-crdt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ pub use engine::{DocHandle, Engine, EngineError, TextMark, TextMarkAttr, YrsEngi
pub use gc::spawn as spawn_gc;
pub use presence::PRESENCE_MAX_BYTES;
pub use registry::{AcquireError, Rooms};
pub use room::{ConnHandle, ConnId, Event, InMsg, Room, RoomHandle};
pub use room::{ConnHandle, ConnId, Event, InMsg, JoinState, Room, RoomHandle};
pub use snapshot::{SnapshotPolicy, SnapshotState};
pub use writer::{Applied, PersistJob};
Loading
Loading