nodes: a write changes only what it sends

`PUT /api/nodes/{id}` wrote the whole node, and a field left out was taken as
empty: a rename sent without the body erased the text. The browser always sent
it, which was the other half of the trouble. To rename a document it sent the
body it had — the draft for one open here, a copy fetched a moment earlier for
any other — and without the version it had seen, so a save made in another
tab, on the phone or over the mount in between was written over, kept only as
a revision nobody was told about.

Now `name` and `body`, like the rest, change only when they are sent. A rename
sends the name alone, and for a document open in a tile the `updated_at` that
tile last saw, so a rename of a document saved elsewhere since is refused the
way `:w` is, rather than stamping a new version over it that the next autosave
would then write the draft past. Toggling a space's board sends only that.
This commit is contained in:
nvrl committed 2026-10-21 12:18:26 +02:00
1 parent 10103b81be
commit 3a6ad9453e
6 files changed
+79 -26

No files matched your search

+17 -10
View File
@@ -160,10 +160,12 @@ pub struct CreateInput {
#[derive(Deserialize)]
pub struct UpdateInput {
/// Left out, the name stays as it is: a write changes only what it sends,
/// so a rename cannot empty a document and a save cannot rename one.
#[serde(default)]
pub name: String,
pub name: Option<String>,
#[serde(default)]
pub body: String,
pub body: Option<String>,
#[serde(default)]
pub created_at: Option<i64>,
/// Spaces only, and only when it is sent at all.
@@ -713,12 +715,13 @@ pub async fn update(
// here is still free when it is written, and a file's name and its
// download name never disagree.
let mut tx = crate::db::begin_write(&state.db).await?;
let row =
sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFound)?;
let row = sqlx::query(
"SELECT parent_id, kind, name, slug, body, updated_at FROM nodes WHERE id = ?1",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFound)?;
if input
.base_updated_at
@@ -732,7 +735,11 @@ pub async fn update(
let parent_id: Option<i64> = row.get("parent_id");
let kind: String = row.get("kind");
let previous_name: String = row.get("name");
let name = input.name.trim();
let previous_body: String = row.get("body");
let name = input
.name
.as_deref()
.map_or(previous_name.as_str(), str::trim);
// The slug follows the name, because a path nobody can read is no better
// than an id. It only moves when the name does, so a link to an unrenamed
@@ -752,7 +759,7 @@ pub async fn update(
let body = if kind == FILE {
""
} else {
input.body.as_str()
input.body.as_deref().unwrap_or(&previous_body)
};
let reason = if input.restore {
revisions::Reason::Restore
+48
View File
@@ -319,6 +319,54 @@ mod tests {
);
}
/// A write changes what it sends and nothing else: a rename leaves the
/// text, and keeps no revision since none was lost. One that carries the
/// version it saw is refused once the document has moved on.
#[tokio::test]
async fn a_rename_leaves_the_text() {
use axum::extract::{Path, State};
let state = crate::routes::tests::state_in(".".into()).await;
let id = document(&state.db, "plan", "the text").await;
let update = |input: serde_json::Value| {
let state = state.clone();
async move {
crate::routes::nodes::update(
crate::auth::Session { id: "test".into() },
State(state),
Path(id),
axum::Json(serde_json::from_value(input).unwrap()),
)
.await
}
};
let renamed = update(serde_json::json!({ "name": "the plan" }))
.await
.unwrap();
assert_eq!(
(renamed.name.as_str(), renamed.body.as_str()),
("the plan", "the text")
);
let edited = update(serde_json::json!({ "body": "new text" }))
.await
.unwrap();
assert_eq!(
(edited.name.as_str(), edited.body.as_str()),
("the plan", "new text")
);
let stale = update(serde_json::json!({ "name": "late", "base_updated_at": 0 })).await;
assert!(matches!(stale, Err(crate::error::AppError::Conflict(_))));
let kept: Vec<String> = sqlx::query_scalar("SELECT body FROM revisions WHERE node_id = ?1")
.bind(id)
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(kept, ["the text"]);
}
/// Old revisions go, except the newest few of every document.
#[tokio::test]
async fn the_newest_stay_however_old() {