//! Folder-sync bookkeeping: the folders this device mirrors, and the per-file //! state each pass reconciles against. use rusqlite::{OptionalExtension, params}; use super::Db; use crate::Result; use std::collections::HashMap; pub struct StoredSyncFolder { pub id: i64, pub local_path: String, pub remote_uid: String, pub remote_share_id: String, /// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount). pub mode: String, /// A mode the user asked for that could not be applied on the spot — the /// folder was mid-pass, or had un-uploaded changes. The engine applies it /// once the folder is safe to switch, so the request is queued rather than /// rejected. `None` when the folder is where the user wants it. pub pending_mode: Option, /// `idle` | `syncing` | `error` | `conflict`. pub state: String, pub last_sync: i64, } /// One per-file sync baseline row: what a path looked like on both sides at the /// last successful sync, so the next reconcile can tell which side changed /// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature — /// its modification time and size as strings — since no cheap content hash is /// exposed; change detection is `(mtime, size)` on each side. #[derive(Clone, Debug, PartialEq)] pub struct StoredSyncEntry { pub rel_path: String, pub remote_uid: Option, pub local_mtime: i64, pub local_size: i64, /// The same modification time to nanosecond precision, when it is known. /// /// `local_mtime` alone cannot tell an edit that landed inside the same /// second as the last sync — and left the file the same length — from no /// edit at all, so those never uploaded (bugs.md B25). `None` on a row /// written before schema 28, which the comparison reads as "compare whole /// seconds", so upgrading does not make an entire mirror look changed. pub local_mtime_ns: Option, pub remote_rev: Option, pub remote_hash: Option, } impl Db { pub fn sync_folder_add( &self, local_path: &str, remote_uid: &str, remote_share_id: &str, ) -> Result { let conn = self.conn.lock(); conn.execute( "INSERT INTO sync_folder (local_path, remote_uid, remote_share_id) VALUES (?1, ?2, ?3)", params![local_path, remote_uid, remote_share_id], )?; Ok(conn.last_insert_rowid()) } /// Every synced folder, oldest first. pub fn sync_folder_list(&self) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync FROM sync_folder ORDER BY id", )?; let rows = stmt.query_map([], |r| { Ok(StoredSyncFolder { id: r.get(0)?, local_path: r.get(1)?, remote_uid: r.get(2)?, remote_share_id: r.get(3)?, mode: r.get(4)?, pending_mode: r.get(5)?, state: r.get(6)?, last_sync: r.get(7)?, }) })?; let mut items = Vec::new(); for row in rows { items.push(row?); } Ok(items) } /// Look up one synced folder by id. pub fn sync_folder_get(&self, id: i64) -> Result> { let conn = self.read(); conn.query_row( "SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync FROM sync_folder WHERE id = ?1", params![id], |r| { Ok(StoredSyncFolder { id: r.get(0)?, local_path: r.get(1)?, remote_uid: r.get(2)?, remote_share_id: r.get(3)?, mode: r.get(4)?, pending_mode: r.get(5)?, state: r.get(6)?, last_sync: r.get(7)?, }) }, ) .optional() .map_err(Into::into) } /// Remove a synced folder and its per-file baseline. pub fn sync_folder_remove(&self, id: i64) -> Result { let mut conn = self.conn.lock(); let tx = conn.transaction()?; tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?; let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?; tx.commit()?; Ok(n > 0) } /// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for /// that mode is satisfied and cleared in the same write. An opposite pending /// mode arrived after this transition started, so preserve it as the newer /// last-request intent. pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> { let conn = self.conn.lock(); conn.execute( "UPDATE sync_folder SET mode = ?2, pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END WHERE id = ?1", params![id, mode], )?; Ok(()) } /// Queue (or, with `None`, withdraw) a mode the folder should move to once it /// is safe to switch. pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> { let conn = self.conn.lock(); conn.execute( "UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1", params![id, mode], )?; Ok(()) } /// Withdraw a queued mode only if it is still the intent the caller /// inspected. A newer request must survive an older settlement attempt. pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result { let conn = self.conn.lock(); let changed = conn.execute( "UPDATE sync_folder SET pending_mode = NULL WHERE id = ?1 AND pending_mode = ?2", params![id, expected], )?; Ok(changed > 0) } /// Update a synced folder's state and stamp `last_sync` to now. pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> { let conn = self.conn.lock(); conn.execute( "UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1", params![id, state, last_sync], )?; Ok(()) } /// The whole per-file sync baseline for a folder, keyed by relative path. pub fn sync_entries(&self, folder_id: i64) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, local_mtime_ns FROM sync_entry WHERE folder_id = ?1", )?; let rows = stmt.query_map(params![folder_id], |r| { Ok(StoredSyncEntry { rel_path: r.get(0)?, remote_uid: r.get(1)?, local_mtime: r.get(2)?, local_size: r.get(3)?, remote_rev: r.get(4)?, remote_hash: r.get(5)?, local_mtime_ns: r.get(6)?, }) })?; let mut map = HashMap::new(); for row in rows { let e = row?; map.insert(e.rel_path.clone(), e); } Ok(map) } /// Whether `uid` is represented by a mirror location. /// /// An on-demand location proves residency through its live inode state. /// Mirror locations have no FUSE state, so their folder root comes from /// `sync_folder` and descendants come from the last successful /// `sync_entry` baseline. pub fn mirror_contains_uid(&self, uid: &str) -> Result { let conn = self.read(); conn.query_row( "SELECT EXISTS( SELECT 1 FROM sync_folder AS folder LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id WHERE folder.mode = 'mirror' AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1) )", params![uid], |row| row.get(0), ) .map_err(Into::into) } /// Insert or replace one baseline row. pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> { let conn = self.conn.lock(); conn.execute( "INSERT INTO sync_entry (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, local_mtime_ns) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(folder_id, rel_path) DO UPDATE SET remote_uid = excluded.remote_uid, local_mtime = excluded.local_mtime, local_size = excluded.local_size, remote_rev = excluded.remote_rev, remote_hash = excluded.remote_hash, local_mtime_ns = excluded.local_mtime_ns", params![ folder_id, e.rel_path, e.remote_uid, e.local_mtime, e.local_size, e.remote_rev, e.remote_hash, e.local_mtime_ns, ], )?; Ok(()) } /// Drop one baseline row (a path that left the sync set on both sides). pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> { let conn = self.conn.lock(); conn.execute( "DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2", params![folder_id, rel_path], )?; Ok(()) } /// Drop the entire baseline for a folder. Used when flipping ondemand→mirror: /// the local tree was evicted, so the old baseline is stale and would make the /// next reconcile mistake "locally deleted" for "must re-download". Clearing it /// leaves an empty baseline + full remote = pure download (devices.md P3). pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> { let conn = self.conn.lock(); conn.execute( "DELETE FROM sync_entry WHERE folder_id = ?1", params![folder_id], )?; Ok(()) } }