mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
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:
1 parent
10103b81be
commit
3a6ad9453e
6 files changed
+79
-26
No files matched your search
+17
-10
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user