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
+116 -2
View File
@@ -281,14 +281,41 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
Ok(())
}
/// Whether this device removed the row and the server hasn't acknowledged it yet.
/// Such a row is still on the server, so it is still in the feed — and putting it
/// back would undo a removal that is only waiting for the next push.
fn removed_here(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<bool> {
let found: Option<i64> = conn
.query_row(
"SELECT 1 FROM pending_deletes WHERE entity = ?1 AND id = ?2",
params![entity, id],
|r| r.get(0),
)
.optional()?;
Ok(found.is_some())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// Only rows the server already had are replaced. A file attached here and still
// waiting to upload exists nowhere else yet, and deleting it would lose it; once
// it has gone up, the server's copy arrives under the same id and takes its place.
conn.execute(
"DELETE FROM attachments WHERE note_id = ?1",
"DELETE FROM attachments WHERE note_id = ?1 AND uploaded = 1",
params![note.id],
)?;
for (index, att) in note.attachments.iter().enumerate() {
if removed_here(conn, "attachment", &att.id)? {
continue;
}
// The server listing an id this device is still waiting to upload means the
// upload landed and only its reply was lost. The server's row replaces it.
conn.execute(
"DELETE FROM attachments WHERE id = ?1 AND uploaded = 0",
params![att.id],
)?;
// The feed carries no explicit position for attachments — they arrive in
// creation order, so the index preserves it.
// creation order, so the index preserves it. A plain INSERT, so an id listed
// twice fails the page rather than being quietly merged.
conn.execute(
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
@@ -313,6 +340,9 @@ fn replace_previews(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()
params![note.id],
)?;
for (index, preview) in note.previews.iter().enumerate() {
if removed_here(conn, "preview", &preview.id)? {
continue;
}
conn.execute(
"INSERT INTO link_previews (id, note_id, url, title, description, image_url,
site_name, position)
@@ -778,4 +808,88 @@ mod tests {
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
fn preview(id: &str) -> wire::Preview {
wire::Preview {
id: id.to_string(),
url: "https://example.com/".into(),
title: None,
description: None,
image_url: None,
site_name: None,
}
}
fn queued_upload(conn: &Connection, id: &str, note_id: &str) {
conn.execute(
"INSERT INTO attachments (id, note_id, url, uploaded) VALUES (?1, ?2, '/x', 0)",
params![id, note_id],
)
.expect("queued upload");
}
fn attachment_ids(conn: &Connection) -> Vec<String> {
let mut stmt = conn
.prepare("SELECT id FROM attachments ORDER BY id")
.expect("prepare");
let rows = stmt.query_map([], |r| r.get(0)).expect("query");
rows.collect::<rusqlite::Result<_>>().expect("rows")
}
#[test]
fn a_pull_keeps_a_file_still_waiting_to_upload() {
// It exists only on this device until push sends it; a pull that replaced the
// note's attachments wholesale would delete the only copy.
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
queued_upload(&conn, "mine", "n1");
let mut changed = note("n1", 2);
changed.attachments = vec![attachment("theirs")];
apply_page(&conn, &page(vec![changed], vec![], 2)).expect("apply");
assert_eq!(attachment_ids(&conn), vec!["mine", "theirs"]);
}
#[test]
fn the_servers_copy_takes_over_from_an_upload_whose_reply_was_lost() {
let conn = db();
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
queued_upload(&conn, "a1", "n1");
let mut listed = note("n1", 2);
listed.attachments = vec![attachment("a1")];
apply_page(&conn, &page(vec![listed], vec![], 2)).expect("apply");
assert_eq!(attachment_ids(&conn), vec!["a1"]);
let uploaded: bool = conn
.query_row(
"SELECT uploaded FROM attachments WHERE id = 'a1'",
[],
|r| r.get(0),
)
.expect("row");
assert!(uploaded, "not sent a second time");
}
#[test]
fn a_removal_waiting_to_be_pushed_is_not_undone_by_a_pull() {
let conn = db();
let mut first = note("n1", 1);
first.attachments = vec![attachment("a1")];
first.previews = vec![preview("p1")];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
crate::local::store::delete_attachment(&conn, "n1", "a1").expect("remove");
crate::local::store::delete_preview(&conn, "n1", "p1").expect("dismiss");
// The server hasn't heard yet, so its copy of the note still lists both.
let mut stale = note("n1", 2);
stale.attachments = vec![attachment("a1")];
stale.previews = vec![preview("p1")];
apply_page(&conn, &page(vec![stale], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM link_previews"), 0);
}
}