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

+1 -1
View File
@@ -840,7 +840,7 @@ sealed or not.
| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault |
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update only the fields sent — `name`, `body`, `created_at`, `has_board`; with `base_updated_at`, refused with `409` once it was saved since / delete into the trash, subtree and all — or for good, for something already in it |
| `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it |
| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good |
| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone |
+1 -1
View File
@@ -15,7 +15,7 @@ sealed or not.
| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault |
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update only the fields sent — `name`, `body`, `created_at`, `has_board`; with `base_updated_at`, refused with `409` once it was saved since / delete into the trash, subtree and all — or for good, for something already in it |
| `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it |
| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good |
| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone |
+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() {
+3 -2
View File
@@ -478,6 +478,7 @@ export const api = {
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
/**
* Only what is sent changes: a rename sends the name alone.
* `base_updated_at`: refused with 409 if the node was saved since then.
* `restore`: the text is a revision brought back, so what it replaces is
* kept as a revision whatever the last one was.
@@ -485,8 +486,8 @@ export const api = {
updateNode: (
id: number,
input: {
name: string
body: string
name?: string
body?: string
created_at?: number
has_board?: boolean
base_updated_at?: number
+9 -12
View File
@@ -1163,19 +1163,16 @@ class Workspace {
}
/**
* Rename anything in the tree — a document, a folder or a space. The body
* has to ride along because the update is a whole-node write; for the node
* being edited that is the draft, which would otherwise be rolled back to
* whatever the server still holds.
* Rename anything in the tree — a document, a folder or a space. Only the
* name is sent, so the text on the server stays as it is and a draft still
* being typed is saved by autosave as before. When the document is open
* here, the rename carries the version this tab last saw: one saved
* somewhere else since is not stamped over, which would let the next
* autosave write the draft over that save without a word.
*/
async renameNode(id: number, name: string) {
const current =
this.open?.id === id ? { ...this.open, body: this.draft.body } : await api.getNode(id)
const saved = await api.updateNode(id, {
name: name.trim(),
body: current.body,
created_at: current.created_at,
})
const open = this.tilesWith(id)[0]?.open
const saved = await api.updateNode(id, { name: name.trim(), base_updated_at: open?.updated_at })
for (const tile of this.tilesWith(id)) {
tile.open = saved
tile.draft.name = saved.name
@@ -1712,7 +1709,7 @@ class Workspace {
async setBoard(on: boolean) {
const space = this.space
if (!space) return
await api.updateNode(space.id, { name: space.name, body: '', has_board: on })
await api.updateNode(space.id, { has_board: on })
this.spaces = await api.listSpaces()
if (on) await this.openBoard()
else {