M10.7c: push + the full sync cycle (task 2106)
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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user