desktop: syncs on its own — at launch, soon after an edit, every few minutes and on focus
Android / Build, or is the channel already serving this? (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m47s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 11m34s

Until now the only caller of the sync engine was the "Sync now" button. A worker
thread now owns every cycle (the button's included, so two never overlap):

- launch: one cycle as the app opens;
- edit: every 10s it reads a fingerprint of the pending set and sends when that
  moved. A fingerprint rather than "anything pending", because a rejected change
  stays pending and would otherwise be resent every tick forever;
- timer: a pull every 5 minutes with nothing to send;
- focus: at most once per 30s.

Failed automatic cycles back off (doubling from 10s to 5 minutes). A panicking
cycle counts as a failed one rather than ending the thread. Every cycle is
emitted as inkwell://synced: the board reloads when the pull changed something,
and the Sync screen shows the last automatic failure. No final push on quit;
the launch cycle sends whatever was left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:40:27 -04:00
co-authored by Claude Opus 5.5
parent 09239ee3c7
commit efb141e555
8 changed files with 586 additions and 43 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ use super::push;
use super::state;
use crate::local::Db;
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct SyncOutcome {
pub push: push::PushSummary,
pub pull: pull::PullSummary,
+50
View File
@@ -422,6 +422,25 @@ pub fn has_pending(conn: &Connection) -> rusqlite::Result<bool> {
Ok(pending.is_some())
}
/// A value that changes whenever the set of pending changes does, and stays put
/// while it doesn't. `None` when nothing is pending.
///
/// For a background syncer deciding whether there is anything NEW to send.
/// `has_pending` cannot answer that: a change the server rejected stays dirty on
/// purpose (it needs a person), so "something is pending" stays true after every
/// cycle, and a loop keyed on it would resend the same rejected row forever. Any
/// local write moves a count or stamps a later `updated_at`, so it moves this too.
pub fn pending_fingerprint(conn: &Connection) -> rusqlite::Result<Option<String>> {
let fingerprint: String = conn.query_row(
"SELECT (SELECT COUNT(*) || ':' || IFNULL(MAX(updated_at), '') FROM notes WHERE dirty = 1)
|| '|' || (SELECT COUNT(*) || ':' || IFNULL(MAX(updated_at), '') FROM labels WHERE dirty = 1)
|| '|' || (SELECT COUNT(*) || ':' || IFNULL(MAX(deleted_at), '') FROM pending_deletes)",
[],
|r| r.get(0),
)?;
Ok((fingerprint != "0:|0:|0:").then_some(fingerprint))
}
/// Send everything pending, in batches, applying each batch's results before the
/// next is collected.
pub async fn run(db: &Db, base_url: &str, token: &str) -> Result<PushSummary, String> {
@@ -711,4 +730,35 @@ mod tests {
"a delete carries no content: {json}"
);
}
#[test]
fn the_pending_fingerprint_moves_only_when_the_pending_set_does() {
let conn = db();
assert_eq!(pending_fingerprint(&conn).unwrap(), None);
seed_note(&conn, "n1", 1);
let one = pending_fingerprint(&conn).unwrap().expect("one dirty note");
// Asking again changes nothing: a rejected row left dirty is not news.
assert_eq!(pending_fingerprint(&conn).unwrap(), Some(one.clone()));
seed_note(&conn, "n2", 1);
let two = pending_fingerprint(&conn)
.unwrap()
.expect("two dirty notes");
assert_ne!(one, two, "another pending note is new work");
conn.execute(
"UPDATE notes SET updated_at = '2026-07-27T00:00:00.000Z' WHERE id = 'n1'",
[],
)
.unwrap();
assert_ne!(
pending_fingerprint(&conn).unwrap(),
Some(two),
"a fresh edit is new work"
);
conn.execute("UPDATE notes SET dirty = 0", []).unwrap();
assert_eq!(pending_fingerprint(&conn).unwrap(), None);
}
}