M10.7c: push + the full sync cycle (task 2106)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m46s

Local -> server, then push-then-pull as the only ordering the UI can invoke.

LOCAL TOMBSTONES (schema v2). Found while writing push: delete_forever and
remove_label just DROPPED the row, leaving no record it existed. Offline that
means the delete can never be pushed — and the next pull faithfully
resurrects the note from the server. A deletion that undoes itself is about
the worst thing sync can do, so deletes now record into pending_deletes until
the server acknowledges them. merge_labels had the same hole.

merge_labels also moved memberships without marking the affected notes dirty.
A note's label set only reaches the server via the note itself, so a merge
looked done locally and never synced. Now marked before the delete cascades
the rows away.

Result handling, per status:
  created/applied -> clear dirty, store the returned sync_revision
  noop            -> clear dirty, drop the tombstone (a row the server never
                     saw, created and deleted entirely offline)
  kept            -> clear dirty WITHOUT touching content. Re-pushing would
                     lose the same last-write-wins comparison forever; the
                     following pull adopts the server's version.
  rejected        -> stay dirty and surface the reason. A duplicate label name
                     is the realistic case and only a human can resolve it.

The subtle one is `kept` plus a skewed clock. Normally the server's kept
revision sits above our cursor, so the next pull fetches it anyway. If the
clock makes a genuinely later local edit look older, that revision can be
BELOW the cursor — the pull skips it and the stale local copy stays on screen
with nothing marking it wrong. So a kept result at or below the cursor
rewinds the cursor to re-fetch that note. Both directions tested.

label_ids carries MANUAL memberships only. Tag-sourced ones are re-derived
server-side from the body; sending them would convert them into manual
assignments that no longer disappear when the #tag is deleted from the text.

engine::run_cycle is push-then-pull, and a failed push ABORTS before the
pull — pulling anyway would overwrite the exact rows we just failed to save,
turning a recoverable network error into lost work. sync_pull is removed from
the command surface accordingly: offering a bare pull would hand the UI a way
to discard unsent edits. sync_now and sync_has_pending replace it.

Both loops have anti-spin guards: push stops when a batch clears nothing,
pull stops when the cursor doesn't advance.

15 push tests against an in-memory database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
2026-07-26 00:20:09 -04:00
co-authored by Claude Opus 5
parent 2e32ecda6e
commit b5f7dc2635
8 changed files with 892 additions and 9 deletions
+16 -8
View File
@@ -9,7 +9,8 @@ use tauri::State;
use crate::local::Db;
use crate::sync::client::{self, Identity, ProbeResult};
use crate::sync::compat::Compatibility;
use crate::sync::pull;
use crate::sync::engine;
use crate::sync::push;
use crate::sync::state;
/// Ask a server who it is, without committing to anything. The UI calls this as the
@@ -135,14 +136,21 @@ fn credentials(db: &State<'_, Db>) -> Result<(String, String), String> {
}
}
/// Pull the server's changes into the local store.
/// Run one full sync: push local changes, then pull the server's.
///
/// Standalone for now; M10.7c wraps push-then-pull into a single `sync_now`, which
/// is the ordering the protocol assumes. Run alone against unpushed local edits, the
/// server's version lands on top of them — the returned `clobbered_dirty` count
/// reports that rather than hiding it.
/// The only sync entry point exposed to the UI, on purpose. Push and pull exist
/// separately inside the crate, but offering a bare "pull" would let the UI overwrite
/// unsent local edits — the ordering isn't a suggestion, it's what keeps them.
#[tauri::command]
pub async fn sync_pull(db: State<'_, Db>) -> Result<pull::PullSummary, String> {
pub async fn sync_now(db: State<'_, Db>) -> Result<engine::SyncOutcome, String> {
let (base_url, token) = credentials(&db)?;
pull::run(db.inner(), &base_url, &token).await
engine::run_cycle(db.inner(), &base_url, &token).await
}
/// Whether anything is waiting to be sent. Lets the UI show an honest "unsynced
/// changes" state without running a sync to find out.
#[tauri::command]
pub fn sync_has_pending(db: State<'_, Db>) -> Result<bool, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
push::has_pending(&conn).map_err(|e| e.to_string())
}