attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s

Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 10:07:52 -04:00
co-authored by Claude Opus 5.5
parent efb141e555
commit 2b2ceaa82e
30 changed files with 1391 additions and 151 deletions
+70
View File
@@ -27,6 +27,11 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// failing one at ten seconds would make a large store impossible to ever pull.
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
/// One file going up can be far larger than a page of notes, and a timeout shorter
/// than the transfer is not a failure that retrying ever fixes — the same upload
/// would time out again every cycle.
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
/// Shared by every call that presents a token, so a revoked one reads the same way
/// wherever it surfaces.
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
@@ -314,6 +319,71 @@ pub async fn fetch_attachment(
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
}
/// Why an upload didn't land, split by whether trying again could help.
#[derive(Debug, PartialEq, Eq)]
pub enum UploadError {
/// Worth another attempt next cycle: the network, a server error, or a note the
/// server hasn't got yet.
Retry(String),
/// The server refused this file and will refuse it the same way every time —
/// too large, an id in use, bytes that don't match their hash.
Refused(String),
}
/// Upload one file attached on this device, under the id it was given here.
///
/// `PUT /api/sync/attachments/<id>` takes the raw bytes; idempotent, so a retry of
/// an upload whose reply was lost answers 200 and stores nothing twice.
#[allow(clippy::too_many_arguments)]
pub async fn upload_attachment(
base_url: &str,
token: &str,
note_id: &str,
attachment_id: &str,
filename: &str,
mime: &str,
sha256: &str,
bytes: Vec<u8>,
) -> Result<(), UploadError> {
let url = format!("{base_url}/api/sync/attachments/{attachment_id}");
let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?;
let request = prepare(client.put(url), Some(token))
.query(&[
("note_id", note_id),
("filename", filename),
("sha256", sha256),
])
.header(reqwest::header::CONTENT_TYPE, mime)
.body(bytes);
let response = request
.send()
.await
.map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(UploadError::Retry(TOKEN_REJECTED.to_string()));
}
// The server's own words, when it gave some: "file is too large (max 25 MB)" is
// what a person can act on, and a bare status code is not.
let reason = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
if status.is_client_error() && status != StatusCode::NOT_FOUND {
Err(UploadError::Refused(reason))
} else {
// 404: the note isn't on the server yet (its push was rejected, say). 5xx:
// the server's problem, and likely a passing one.
Err(UploadError::Retry(reason))
}
}
/// Send a batch of changes and hand back the raw reply.
///
/// Returns text rather than parsed results so this module stays pure transport —