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
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:
+116
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user