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