//! Unified SQLite metadata cache — the single persistence layer behind FUSE //! inode bookkeeping, full-text search, content-cache LRU tracking, and pins. //! //! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach //! the same data through the control socket. The connection is wrapped in a //! `Mutex` because the FUSE callbacks are synchronous and already serialize //! behind the `State` lock, so a connection pool would be overkill. //! //! This module is the P0 foundation: it opens the database, enables WAL, and //! applies the forward-only schema migrations. Write-through of nodes, the //! event cursor, FTS, and the cache index land in later phases on this schema. use std::collections::HashMap; use std::path::Path; use std::sync::Mutex; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{Node, NodeKind}; use rusqlite::{Connection, OptionalExtension, params}; use crate::Result; use crate::control::{ActivityEntry, ActivityKind}; use crate::localindex::LocalEntry; /// Current schema version. Bump on every forward migration added below. const SCHEMA_VERSION: i64 = 12; /// How many activity rows to keep. Older rows are pruned on insert, so the feed /// stays a bounded "recent history" rather than growing without limit. const ACTIVITY_KEEP: i64 = 2000; /// The `kind` of a [`PendingOp`] that uploads a staged file as a new revision. pub const OP_REVISION: &str = "revision"; /// The `kind` of a [`PendingOp`] that creates a file that so far exists only /// locally, under a `local:` placeholder uid (offline.md Phase 3b). /// /// The written bytes ride along on the same row rather than as a follow-on /// [`OP_REVISION`]: draining the create mints the node's real uid, which would /// leave a separate revision op addressed to a uid that no longer exists. pub const OP_CREATE: &str = "create"; /// The `kind` of a [`PendingOp`] that creates a folder that so far exists only /// locally. Ordering matters: a child's op is queued after its parent's and /// [`Db::pending_ops`] replays by row id, so the parent has a real uid by the /// time the child drains. pub const OP_MKDIR: &str = "mkdir"; /// The volume id given to a node that exists only on this machine, so far. A /// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~` and /// round-trips through the same `Display`/parse path as any other uid. /// /// Nothing bearing this volume may be handed to the API — it would 404. The /// drain replaces it with the uid the server assigns. pub const LOCAL_VOLUME: &str = "local"; /// A mutation that has been accepted locally but not yet performed against the /// API — the durable half of the write-back queue (offline.md Phase 3). /// /// The daemon answers the FUSE call the moment this row and its staged blob are /// on disk, so a `cp` into the mount runs at disk speed and the upload happens /// behind it. That also makes an offline write succeed rather than EIO: the row /// simply waits for the network. #[derive(Debug, Clone)] pub struct PendingOp { /// Row id, `0` on a value being inserted. pub id: i64, /// See [`OP_REVISION`], [`OP_CREATE`], [`OP_MKDIR`]. pub kind: String, /// Node this op targets. For [`OP_CREATE`]/[`OP_MKDIR`] this is the /// `local~` placeholder the node is known by until it drains. pub uid: String, /// Where the new node goes; only set for [`OP_CREATE`]/[`OP_MKDIR`]. May /// itself be a placeholder when the parent folder is also still queued, in /// which case it is rewritten when the parent drains. pub parent_uid: Option, /// The new node's name; only set for [`OP_CREATE`]/[`OP_MKDIR`]. pub name: Option, /// Staged blob holding the bytes to upload. pub blob_path: Option, /// Serialized [`StagedWrite`](crate::cache::StagedWrite). pub meta_json: Option, /// When the op was queued (ms since epoch). pub created_at: i64, pub attempts: i64, pub last_error: Option, /// Earliest ms at which to retry, for backoff. pub next_attempt_at: i64, } /// The outcome of folding freshly written bytes into a queued create. #[derive(Debug, Clone)] pub struct AttachedBlob { /// Row id of the create the bytes were attached to. pub id: i64, /// Blob the create held before, now orphaned. pub superseded: Option, } /// Size the WAL is truncated back to after a checkpoint. Comfortably above the /// steady-state working set (a few MB), so the truncation only claws back the /// outliers rather than fighting the normal write path for disk. const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024; /// A photo whose thumbnail state is not known yet: it has never been asked for. pub const THUMB_UNKNOWN: i64 = 0; /// A thumbnail exists for this photo — served by the server, or generated locally /// from the full file when the server had none. pub const THUMB_HAVE: i64 = 1; /// This photo can never be given a thumbnail: the server has none and the bytes /// could not be decoded locally either. Never retried. pub const THUMB_NONE: i64 = 2; /// Below this length the trigram tokenizer indexes nothing (it needs 3-char /// grams), so short queries fall back to a `LIKE` scan over `nodes.name`. const TRIGRAM_MIN: usize = 3; /// A node loaded back from the database, paired with whether its directory /// listing was complete (`listed`) when last persisted. Only meaningful for /// folders; always `false` for files. pub struct StoredNode { pub node: Node, pub listed: bool, } /// One full-text search match: the stored [`Node`] plus its mountpoint-relative /// path (`/`-joined, root excluded) so the front-end can navigate to or open it. pub struct SearchHit { pub node: Node, pub path: String, } /// One photo of the persisted timeline. The timeline itself is server-ordered /// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state` /// are locally learned and survive a refresh of the timeline. #[derive(Clone, Debug, PartialEq)] pub struct StoredPhoto { pub uid: String, pub capture_time: i64, pub name: Option, /// Aspect ratio (w/h), known once a thumbnail has been decoded. pub ratio: Option, /// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`]. pub thumb_state: i64, } /// One trashed node, as persisted for the Trash page. Trashed nodes live outside /// the mounted tree, so a uid is their only handle. #[derive(Clone, Debug, PartialEq)] pub struct StoredTrash { pub uid: String, pub name: String, pub is_dir: bool, pub size: i64, pub mtime: i64, } /// The one Proton Drive Device this machine is registered as (devices.md). /// Cached so restarts reuse the same device rather than creating a new one. #[derive(Clone, Debug, PartialEq)] pub struct StoredDevice { pub uid: String, pub share_id: String, pub root_uid: String, pub name: String, pub created: i64, } /// One local folder the user added to this device's sync set. #[derive(Clone, Debug, PartialEq)] 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, pub remote_rev: Option, pub remote_hash: Option, } /// Handle to the unified metadata database. /// /// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through /// the inner `Mutex`. pub struct Db { conn: Mutex, } impl Db { /// Open (creating if absent) the database at `path`, enable WAL, and bring /// the schema up to [`SCHEMA_VERSION`]. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; // WAL: readers never block the single writer. NORMAL sync is the // standard durability/throughput tradeoff for WAL. conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; // Hand the WAL's disk back after a checkpoint. Without a limit SQLite // reuses the file in place but never shrinks it, so a single large // transaction (a full-index FTS rebuild in `local_finish_scan` is the // one that reaches this size) leaves the WAL at its high-water mark // forever — a multi-GB file next to a database two orders of magnitude // smaller. Checkpointing is unaffected; only the file is truncated back. conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?; let db = Self { conn: Mutex::new(conn), }; db.migrate()?; Ok(db) } /// Open an in-memory database. For tests. #[cfg(test)] pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory()?; let db = Self { conn: Mutex::new(conn), }; db.migrate()?; Ok(db) } /// Run forward-only migrations from the stored `schema_version` up to /// [`SCHEMA_VERSION`]. Each step is wrapped in its own transaction. fn migrate(&self) -> Result<()> { let mut conn = self.conn.lock().unwrap(); // `sync_state` is the key/value table holding `schema_version` and the // event cursor (later). Create it first so we can read the version. conn.execute_batch( "CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT);", )?; let current: i64 = conn .query_row( "SELECT value FROM sync_state WHERE key = 'schema_version'", [], |row| row.get::<_, String>(0), ) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0); if current >= SCHEMA_VERSION { return Ok(()); } let tx = conn.transaction()?; if current < 1 { tx.execute_batch(MIGRATION_V1)?; } if current < 2 { tx.execute_batch(MIGRATION_V2)?; } if current < 3 { tx.execute_batch(MIGRATION_V3)?; } if current < 4 { tx.execute_batch(MIGRATION_V4)?; } if current < 5 { tx.execute_batch(MIGRATION_V5)?; } if current < 6 { tx.execute_batch(MIGRATION_V6)?; } if current < 7 { tx.execute_batch(MIGRATION_V7)?; } if current < 8 { tx.execute_batch(MIGRATION_V8)?; } if current < 9 { tx.execute_batch(MIGRATION_V9)?; } if current < 10 { tx.execute_batch(MIGRATION_V10)?; } if current < 11 { tx.execute_batch(MIGRATION_V11)?; } if current < 12 { tx.execute_batch(MIGRATION_V12)?; } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [SCHEMA_VERSION.to_string()], )?; tx.commit()?; Ok(()) } /// Run a closure with the locked connection. Escape hatch for callers in /// later phases until typed query methods are added. pub fn with_conn(&self, f: impl FnOnce(&Connection) -> Result) -> Result { let conn = self.conn.lock().unwrap(); f(&conn) } /// Write-through a node from the in-memory cache. Indexed columns are kept /// for query/search/eviction; the full [`Node`] is stored as JSON so the /// hot-cache maps rehydrate losslessly on the next mount. `listed` is never /// changed here — it is owned by [`set_listed`](Self::set_listed). pub fn upsert_node(&self, node: &Node) -> Result<()> { let json = serde_json::to_string(node)?; let uid = node.uid.to_string(); let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "INSERT INTO nodes (uid, parent_uid, name, is_dir, size, mtime, trashed, node_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(uid) DO UPDATE SET parent_uid = excluded.parent_uid, name = excluded.name, is_dir = excluded.is_dir, size = excluded.size, mtime = excluded.mtime, trashed = excluded.trashed, node_json = excluded.node_json", params![ uid, node.parent_uid.as_ref().map(|u| u.to_string()), node.name, node.is_folder() as i64, node_size(node), node.modification_time, node.trashed as i64, json, ], )?; // FTS5 has no UPSERT, so refresh the row by delete-then-insert. Trashed // nodes are kept out of the index entirely so they never surface in // search results. tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; if !node.trashed { tx.execute( "INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)", params![uid, node.name], )?; } tx.commit()?; Ok(()) } /// Drop a node row (delete or trash from the hot cache). Children rows are /// not cascaded here; the daemon forgets a whole subtree node-by-node. pub fn delete_node(&self, uid: &NodeUid) -> Result<()> { let uid = uid.to_string(); let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?; tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; tx.commit()?; Ok(()) } /// Full-text search over node names, newest schema's trigram index giving /// substring (not just prefix) matches. Returns up to `limit` non-trashed /// hits, each with its mountpoint-relative path resolved. Queries shorter /// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes /// nothing below 3 chars. pub fn search(&self, query: &str, limit: usize) -> Result> { let query = query.trim(); if query.is_empty() { return Ok(Vec::new()); } let conn = self.conn.lock().unwrap(); let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN { let pat = format!("%{}%", like_escape(query)); let mut stmt = conn.prepare( "SELECT node_json, uid FROM nodes WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL ORDER BY name LIMIT ?2", )?; collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)? } else { // Escape double quotes and quote each term, then combine with AND so // all terms must match but can appear in any order or position. let phrase = query .split_whitespace() .map(|word| format!("\"{}\"", word.replace('"', "\"\""))) .collect::>() .join(" AND "); let mut stmt = conn.prepare( "SELECT n.node_json, n.uid FROM nodes_fts f JOIN nodes n ON n.uid = f.uid WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL ORDER BY f.rank LIMIT ?2", )?; collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)? }; let mut hits = Vec::with_capacity(rows.len()); for (json, uid) in rows { let node: Node = serde_json::from_str(&json)?; let path = path_of(&conn, &uid)?; hits.push(SearchHit { node, path }); } Ok(hits) } /// Mark (or unmark) a folder's child listing as complete. A listed folder /// rehydrates its `children` map on mount even when empty; an unlisted one /// re-enumerates from the remote on next access. pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE nodes SET listed = ?2 WHERE uid = ?1", params![uid.to_string(), listed as i64], )?; Ok(()) } /// Load every persisted node for cold-start hydration of the `State` maps. pub fn load_all(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?; let rows = stmt.query_map([], |row| { let json: String = row.get(0)?; let listed: i64 = row.get(1)?; Ok((json, listed != 0)) })?; let mut out = Vec::new(); for row in rows { let (json, listed) = row?; let node: Node = serde_json::from_str(&json)?; out.push(StoredNode { node, listed }); } Ok(out) } /// Load one persisted node back by uid. Used to recover the My Files root /// when the API is unreachable, so the mount can still serve the cached tree /// (offline.md Phase 1). pub fn node_by_uid(&self, uid: &str) -> Result> { let conn = self.conn.lock().unwrap(); let json: Option = conn .query_row( "SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL", params![uid], |r| r.get(0), ) .optional()?; match json { Some(json) => Ok(Some(serde_json::from_str(&json)?)), None => Ok(None), } } /// Read the persisted incremental-sync cursor (a `DriveEventId`), if any. /// The daemon resumes from this on restart instead of reseeding to the /// server head, so changes made while unmounted are still applied (P2). pub fn get_event_cursor(&self) -> Result> { let conn = self.conn.lock().unwrap(); let v = conn .query_row( "SELECT value FROM sync_state WHERE key = 'event_cursor'", [], |r| r.get::<_, String>(0), ) .optional()?; Ok(v) } /// Persist the incremental-sync cursor after a batch of events is applied. pub fn set_event_cursor(&self, cursor: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![cursor], )?; Ok(()) } /// Read a `sync_state` value as a string. pub fn state_str(&self, key: &str) -> Result> { let conn = self.conn.lock().unwrap(); let v = conn .query_row( "SELECT value FROM sync_state WHERE key = ?1", params![key], |r| r.get::<_, String>(0), ) .optional()?; Ok(v) } /// Write a `sync_state` string value. pub fn set_state_str(&self, key: &str, value: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO sync_state (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], )?; Ok(()) } /// Queue an op to be performed by the drain worker, returning its row id. /// /// One *revision* op per node: a second write to the same file before the /// first has drained replaces it, since the newer blob already contains /// everything the older one did. The superseded blob's path is returned so /// the caller can delete it. /// /// Only revision rows supersede, and only each other. A create op for the /// same uid must survive — dropping it would leave a file that exists /// nowhere but this machine with nothing left to create it. (Writes to a /// node that is itself still queued fold into the create row instead; see /// [`Db::attach_blob_to_create`].) pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option)> { let conn = self.conn.lock().unwrap(); let superseded: Option = conn .query_row( "SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2", params![op.uid, OP_REVISION], |r| r.get(0), ) .optional()? .flatten(); if op.kind == OP_REVISION { conn.execute( "DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2", params![op.uid, OP_REVISION], )?; } conn.execute( "INSERT INTO pending_op (kind, uid, parent_uid, name, blob_path, meta_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ op.kind, op.uid, op.parent_uid, op.name, op.blob_path, op.meta_json, op.created_at ], )?; Ok((conn.last_insert_rowid(), superseded)) } /// Point a queued create at the bytes that were just written to it, returning /// any blob it previously held so the caller can discard it. /// /// This is what `release` does for a file that only exists locally: the node /// has no uid to hang a revision op on yet, so the bytes ride on the create. /// Repeated writes before the drain simply replace the blob. /// /// Returns `Ok(None)` and touches nothing if the create has already drained — /// the caller must then queue an ordinary revision against the real uid. pub fn attach_blob_to_create( &self, uid: &str, blob_path: &str, meta_json: &str, ) -> Result> { let conn = self.conn.lock().unwrap(); let existing: Option<(i64, Option)> = conn .query_row( "SELECT id, blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2", params![uid, OP_CREATE], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; let Some((id, superseded)) = existing else { return Ok(None); }; conn.execute( "UPDATE pending_op SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0 WHERE id = ?1", params![id, blob_path, meta_json], )?; Ok(Some(AttachedBlob { id, superseded })) } /// Drop every op targeting a node, returning the staged blobs they held so /// the caller can delete them. /// /// Used when a node that only ever existed locally is deleted: there is /// nothing on the server to trash, so the queued work simply stops being /// wanted. pub fn delete_ops_for_uid(&self, uid: &str) -> Result> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let blobs: Vec = { let mut stmt = tx.prepare( "SELECT blob_path FROM pending_op WHERE uid = ?1 AND blob_path IS NOT NULL", )?; let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?; rows.collect::, _>>()? }; tx.execute("DELETE FROM pending_op WHERE uid = ?1", params![uid])?; tx.commit()?; Ok(blobs) } /// Rewrite every queued op that points at a placeholder parent, once that /// parent has drained and has a real uid. Also moves the node rows whose /// parent column still names the placeholder, so listings keep resolving. pub fn remap_local_uid(&self, local: &str, real: &str) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1", params![local, real], )?; tx.execute( "UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1", params![local, real], )?; tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?; tx.commit()?; Ok(()) } /// Every queued op, oldest first. The drain worker replays them in this order /// so a file's writes land in the order they were made. pub fn pending_ops(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at FROM pending_op ORDER BY id", )?; let rows = stmt .query_map([], |r| { Ok(PendingOp { id: r.get(0)?, kind: r.get(1)?, uid: r.get(2)?, parent_uid: r.get(3)?, name: r.get(4)?, blob_path: r.get(5)?, meta_json: r.get(6)?, created_at: r.get(7)?, attempts: r.get(8)?, last_error: r.get(9)?, next_attempt_at: r.get(10)?, }) })? .collect::, _>>()?; Ok(rows) } /// How many uploads are still waiting, for `Response::Status`. pub fn pending_op_count(&self) -> Result { let conn = self.conn.lock().unwrap(); Ok(conn.query_row("SELECT COUNT(*) FROM pending_op", [], |r| r.get(0))?) } /// Drop a queued op, once its upload has actually landed. pub fn delete_op(&self, id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?; Ok(()) } /// Record a failed attempt and when to next try. Leaves the row in place — /// the staged bytes are still the only copy of the user's write. pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE pending_op SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3 WHERE id = ?1", params![id, error, next_attempt_at], )?; Ok(()) } /// Read a `sync_state` value as an integer (the freshness stamps of the /// photos timeline and the trash listing are kept there). pub fn state_i64(&self, key: &str) -> Result> { let conn = self.conn.lock().unwrap(); let v: Option = conn .query_row( "SELECT value FROM sync_state WHERE key = ?1", params![key], |r| r.get(0), ) .optional()?; Ok(v.and_then(|v| v.parse().ok())) } /// Write a `sync_state` integer value. pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO sync_state (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value.to_string()], )?; Ok(()) } /// Drop a `sync_state` key, so whatever it stamped counts as never fetched. pub fn clear_state(&self, key: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?; Ok(()) } /// Replace the persisted photos timeline with `items` (newest first), keeping /// what was locally learned about the photos that are still there: a ratio or /// a thumbnail verdict costs a download to rediscover, while the server order /// and capture times are cheap and authoritative. /// /// Photos no longer in the timeline are dropped, so a deletion on another /// client doesn't leave a ghost tile behind. pub fn photos_replace(&self, items: &[(String, i64, Option)]) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let learned: HashMap, i64)> = { let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))))?; rows.collect::>()? }; tx.execute("DELETE FROM photos", [])?; { let mut stmt = tx.prepare( "INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", )?; for (seq, (uid, capture_time, name)) in items.iter().enumerate() { let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); stmt.execute(params![ uid, capture_time, name, ratio, thumb_state, seq as i64 ])?; } } tx.commit()?; Ok(()) } /// One page of the persisted timeline, newest first. pub fn photos_page(&self, offset: usize, limit: usize) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT uid, capture_time, name, ratio, thumb_state FROM photos ORDER BY seq LIMIT ?1 OFFSET ?2", )?; let rows = stmt.query_map(params![limit as i64, offset as i64], |r| { Ok(StoredPhoto { uid: r.get(0)?, capture_time: r.get(1)?, name: r.get(2)?, ratio: r.get(3)?, thumb_state: r.get(4)?, }) })?; let mut photos = Vec::new(); for row in rows { photos.push(row?); } Ok(photos) } /// The stored photos for `uids`, in no particular order. Used by the thumbnail /// path, which needs each photo's capture time (the cache validity tag) and /// its thumbnail verdict. pub fn photos_by_uid(&self, uids: &[String]) -> Result> { if uids.is_empty() { return Ok(Vec::new()); } let conn = self.conn.lock().unwrap(); let placeholders = vec!["?"; uids.len()].join(","); let mut stmt = conn.prepare(&format!( "SELECT uid, capture_time, name, ratio, thumb_state FROM photos WHERE uid IN ({placeholders})" ))?; let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| { Ok(StoredPhoto { uid: r.get(0)?, capture_time: r.get(1)?, name: r.get(2)?, ratio: r.get(3)?, thumb_state: r.get(4)?, }) })?; let mut photos = Vec::new(); for row in rows { photos.push(row?); } Ok(photos) } /// Record what a thumbnail attempt learned: whether the photo now has one /// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were /// seen. A `None` ratio leaves any previously learned one alone. pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1", params![uid, state, ratio], )?; Ok(()) } /// Number of photos in the persisted timeline. pub fn photos_count(&self) -> Result { let conn = self.conn.lock().unwrap(); let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?; Ok(n.max(0) as usize) } /// Replace the persisted trash listing. pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute("DELETE FROM trash", [])?; { let mut stmt = tx.prepare( "INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)", )?; for item in items { stmt.execute(params![ item.uid, item.name, item.is_dir as i64, item.size, item.mtime ])?; } } tx.commit()?; Ok(()) } /// The persisted trash listing, folders first then by name — the order the /// Trash page shows it in. pub fn trash_list(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT uid, name, is_dir, size, mtime FROM trash ORDER BY is_dir DESC, name COLLATE NOCASE", )?; let rows = stmt.query_map([], |r| { Ok(StoredTrash { uid: r.get(0)?, name: r.get(1)?, is_dir: r.get::<_, i64>(2)? != 0, size: r.get(3)?, mtime: r.get(4)?, }) })?; let mut items = Vec::new(); for row in rows { items.push(row?); } Ok(items) } // ---- device sync (devices.md) ----------------------------------------- /// The registered device for this machine, if one has been created/cached. pub fn device_get(&self) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( "SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1", [], |r| { Ok(StoredDevice { uid: r.get(0)?, share_id: r.get(1)?, root_uid: r.get(2)?, name: r.get(3)?, created: r.get(4)?, }) }, ) .optional() .map_err(Into::into) } /// Persist (or replace) this machine's device. The table holds a single row. pub fn device_set(&self, dev: &StoredDevice) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM device", [])?; conn.execute( "INSERT INTO device (uid, share_id, root_uid, name, created) VALUES (?1, ?2, ?3, ?4, ?5)", params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created], )?; Ok(()) } /// Add a synced folder, returning its new row id. pub fn sync_folder_add( &self, local_path: &str, remote_uid: &str, remote_share_id: &str, ) -> Result { let conn = self.conn.lock().unwrap(); 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.conn.lock().unwrap(); 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.conn.lock().unwrap(); 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().unwrap(); 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`). The folder has /// reached the mode it was asked for, so any queued request is satisfied and /// cleared in the same write — a `pending_mode` outliving the switch it asked /// for would have the engine try to apply it again on the next pass. pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE sync_folder SET mode = ?2, pending_mode = NULL 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().unwrap(); conn.execute( "UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1", params![id, mode], )?; Ok(()) } /// 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().unwrap(); 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.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash 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)?, }) })?; let mut map = HashMap::new(); for row in rows { let e = row?; map.insert(e.rel_path.clone(), e); } Ok(map) } /// Insert or replace one baseline row. pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO sync_entry (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) 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", params![ folder_id, e.rel_path, e.remote_uid, e.local_mtime, e.local_size, e.remote_rev, e.remote_hash, ], )?; 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().unwrap(); conn.execute( "DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2", params![folder_id, rel_path], )?; Ok(()) } // ---- activity --------------------------------------------------------- /// Append one entry to the activity log, pruning back to [`ACTIVITY_KEEP`] /// rows. `kind` round-trips through serde rather than a hand-written string /// table, so adding an [`ActivityKind`] variant needs no change here. pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> { let kind = serde_json::to_string(&entry.kind)?; let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)", params![entry.time, kind, entry.target, entry.detail, entry.ok], )?; tx.execute( "DELETE FROM activity WHERE id <= ( SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1 )", params![ACTIVITY_KEEP], )?; tx.commit()?; Ok(()) } /// The most recent activity, newest first, capped at `limit` entries. Rows /// whose stored `kind` no longer parses (written by an older build) are /// skipped rather than failing the whole read. pub fn activity_list(&self, limit: usize) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT time, kind, target, detail, ok FROM activity ORDER BY id DESC LIMIT ?1", )?; let rows = stmt.query_map(params![limit as i64], |r| { Ok(( r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, bool>(4)?, )) })?; let mut out = Vec::new(); for row in rows { let (time, kind, target, detail, ok) = row?; let Ok(kind) = serde_json::from_str::(&kind) else { continue; }; out.push(ActivityEntry { time, kind, target, detail, ok, }); } Ok(out) } /// 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().unwrap(); conn.execute( "DELETE FROM sync_entry WHERE folder_id = ?1", params![folder_id], )?; Ok(()) } /// Children of `parent`, for the offline `ensure_children` fast path when a /// listed folder's `children` map entry was trimmed mid-run. Returns `None` /// when the folder is not marked `listed` (listing unknown, must re-fetch). pub fn children_if_listed(&self, parent: &NodeUid) -> Result>> { let conn = self.conn.lock().unwrap(); let listed: Option = conn .query_row( "SELECT listed FROM nodes WHERE uid = ?1", params![parent.to_string()], |r| r.get(0), ) .optional()?; if listed != Some(1) { return Ok(None); } let mut stmt = conn.prepare( "SELECT node_json FROM nodes WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0", )?; let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?; let mut out = Vec::new(); for json in rows { out.push(serde_json::from_str(&json?)?); } Ok(Some(out)) } // --- Content-cache LRU index (P4) ------------------------------------- // // Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached // blob/block carries one row keyed by its on-disk filename (`cache_key`), // tagged with `kind` ('blob' | 'block') so the two byte budgets stay // separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns // the on-disk cache and rebuilds this index from disk on open, then keeps it // in sync on every store/read/evict, so it is authoritative for eviction. /// Insert or refresh a cache entry: set its on-disk `size` and bump /// `last_accessed` to `now`. Called on every store of a blob or block. pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(cache_key) DO UPDATE SET kind = excluded.kind, size_bytes = excluded.size_bytes, last_accessed = excluded.last_accessed", params![key, kind, size as i64, now], )?; Ok(()) } /// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is /// a no-op — best effort, mirroring the old best-effort mtime touch. pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1", params![key, now], )?; Ok(()) } /// Drop a single cache-entry row (one evicted blob or block). pub fn cache_remove(&self, key: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM cache_entries WHERE cache_key = ?1", params![key], )?; Ok(()) } /// Drop the whole-file blob row for `key` plus every block row for the same /// uid (`.b`). Called by `ContentCache::evict`, which removes the /// blob and all of a uid's cached blocks at once. pub fn cache_remove_all(&self, key: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM cache_entries WHERE cache_key = ?1 OR cache_key LIKE ?2", params![key, format!("{key}.b%")], )?; Ok(()) } /// Every cache entry of `kind`, ordered least-recently-accessed first, as /// `(cache_key, size_bytes)`. The budget enforcer sums the sizes and evicts /// from the front until the cache fits. pub fn cache_entries_by_kind(&self, kind: &str) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT cache_key, size_bytes FROM cache_entries WHERE kind = ?1 ORDER BY last_accessed ASC", )?; let rows = stmt.query_map(params![kind], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) })?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Wipe the entire cache index. The daemon calls this on open before /// rebuilding the index from the on-disk cache, so a crash or external file /// deletion can never leave a phantom row inflating the budget total. pub fn cache_clear(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM cache_entries", [])?; Ok(()) } // --- Pins (P5) -------------------------------------------------------- // // The pin registry, formerly `pins.json`. One row per directly-pinned node, // keyed by uid display string. `recursive` marks a folder pin: its whole // subtree counts as pinned, resolved against `nodes` via a CTE so a new // descendant is covered the moment it lands in the node cache. /// Record `uid` as pinned under display `path`. `recursive` keeps the whole /// subtree of a folder. Idempotent — re-pinning refreshes the path/flag. pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3) ON CONFLICT(uid) DO UPDATE SET path = excluded.path, recursive = excluded.recursive", params![uid, path, recursive as i64], )?; Ok(()) } /// Drop `uid`'s pin row. Returns whether a row existed. pub fn pin_remove(&self, uid: &str) -> Result { let conn = self.conn.lock().unwrap(); let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?; Ok(n > 0) } /// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid. pub fn pin_list(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?; let rows = stmt.query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)? != 0, )) })?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Whether `uid` is pinned — directly, or because a strict ancestor folder /// is pinned recursively. The ancestor check walks `parent_uid` to the root /// via a CTE; a direct pin is honoured even when the node has no `nodes` row /// yet (e.g. a CLI that never hydrates the node cache). pub fn is_pinned(&self, uid: &str) -> Result { let conn = self.conn.lock().unwrap(); let direct: Option = conn .query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| { r.get(0) }) .optional()?; if direct.is_some() { return Ok(true); } let anc: Option = conn .query_row( "WITH RECURSIVE anc(uid, parent_uid) AS ( SELECT uid, parent_uid FROM nodes WHERE uid = ?1 UNION ALL SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid ) SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid WHERE a.uid != ?1 AND p.recursive = 1 LIMIT 1", params![uid], |r| r.get(0), ) .optional()?; Ok(anc.is_some()) } /// Every uid that is pinned, directly or transitively: all direct pins plus /// every descendant of a recursively-pinned folder. Used to build the /// eviction-exempt set (the budget enforcer hashes these into cache keys). pub fn pinned_uids(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "WITH RECURSIVE sub(uid) AS ( SELECT uid FROM pins WHERE recursive = 1 UNION SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid ) SELECT uid FROM sub UNION SELECT uid FROM pins", )?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE. /// Used to evict a recursively-pinned subtree's cached blobs on unpin. pub fn descendants(&self, folder: &str) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "WITH RECURSIVE sub(uid) AS ( SELECT uid FROM nodes WHERE parent_uid = ?1 UNION SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid ) SELECT uid FROM sub", )?; let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } // ---- local (non-Drive) file index (schema v6) ---- /// Open a new local-index scan generation. Rows written under it survive /// [`local_finish_scan`](Self::local_finish_scan); rows still carrying an /// older generation are pruned there as "no longer on disk". pub fn local_begin_scan(&self) -> Result { let conn = self.conn.lock().unwrap(); let current: i64 = conn .query_row( "SELECT value FROM sync_state WHERE key = 'local_scan_gen'", [], |row| row.get::<_, String>(0), ) .optional()? .and_then(|v| v.parse().ok()) .unwrap_or(0); let next = current + 1; conn.execute( "INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [next.to_string()], )?; Ok(next) } /// Write one batch of walked entries under scan generation `generation`. The /// FTS index is *not* touched here — it is rebuilt once in /// [`local_finish_scan`](Self::local_finish_scan). pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; { let mut stmt = tx.prepare_cached( "INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(path) DO UPDATE SET name = excluded.name, is_dir = excluded.is_dir, size = excluded.size, mtime = excluded.mtime, scan_gen = excluded.scan_gen", )?; for e in entries { stmt.execute(params![ e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation ])?; } } tx.commit()?; Ok(()) } /// Close scan generation `generation`: drop every row an older scan wrote /// (those paths are gone from disk), rebuild the FTS index over what remains, /// and stamp the completion time. Returns the number of indexed entries. pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( "DELETE FROM local_files WHERE scan_gen != ?1", params![generation], )?; tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?; tx.execute( "INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [finished_at.to_string()], )?; let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?; tx.commit()?; Ok(count) } /// When the last local scan completed (epoch seconds), or `None` if the index /// has never been built. The daemon uses this to decide whether a fresh mount /// needs an immediate rescan or can serve the existing index. pub fn local_indexed_at(&self) -> Result> { let conn = self.conn.lock().unwrap(); Ok(conn .query_row( "SELECT value FROM sync_state WHERE key = 'local_indexed_at'", [], |row| row.get::<_, String>(0), ) .optional()? .and_then(|v| v.parse().ok())) } /// Substring search over indexed local file names, newest-modified first /// within a relevance tier. Mirrors [`search`](Self::search): the trigram /// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall /// back to a `LIKE` scan. pub fn search_local(&self, query: &str, limit: usize) -> Result> { let query = query.trim(); if query.is_empty() { return Ok(Vec::new()); } let conn = self.conn.lock().unwrap(); let mut stmt; let rows = if query.chars().count() < TRIGRAM_MIN { let pat = format!("%{}%", like_escape(query)); stmt = conn.prepare( "SELECT path, name, is_dir, size, mtime FROM local_files WHERE name LIKE ?1 ESCAPE '\\' ORDER BY mtime DESC LIMIT ?2", )?; stmt.query_map(params![pat, limit as i64], local_hit)? } else { let phrase = query .split_whitespace() .map(|word| format!("\"{}\"", word.replace('"', "\"\""))) .collect::>() .join(" AND "); stmt = conn.prepare( "SELECT f.path, f.name, f.is_dir, f.size, f.mtime FROM local_fts x JOIN local_files f ON f.id = x.rowid WHERE x.name MATCH ?1 ORDER BY x.rank LIMIT ?2", )?; stmt.query_map(params![phrase, limit as i64], local_hit)? }; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } } /// One hit from [`Db::search_local`]: an indexed file on the machine itself, not /// in Drive. `path` is absolute. pub struct LocalFileHit { pub path: String, pub name: String, pub is_dir: bool, pub size: i64, pub mtime: i64, } /// Row mapper for [`Db::search_local`]'s two query paths, which select the same /// columns in the same order. fn local_hit(row: &rusqlite::Row) -> rusqlite::Result { Ok(LocalFileHit { path: row.get(0)?, name: row.get(1)?, is_dir: row.get::<_, i64>(2)? != 0, size: row.get(3)?, mtime: row.get(4)?, }) } /// Row mapper for the `(node_json, uid)` pairs both search paths return. fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> { Ok((row.get(0)?, row.get(1)?)) } /// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors. fn collect_pairs( rows: impl Iterator>, ) -> Result> { let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Escape `LIKE` wildcards in a user query so `%` and `_` match literally /// (paired with `ESCAPE '\'` in the statement). fn like_escape(s: &str) -> String { s.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } /// Resolve a node's mountpoint-relative path by walking `parent_uid` to the /// root via a recursive CTE. The root (the node with no parent) is excluded, so /// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`. fn path_of(conn: &Connection, uid: &str) -> Result { let mut stmt = conn.prepare( "WITH RECURSIVE anc(uid, parent_uid, name, depth) AS ( SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1 UNION ALL SELECT n.uid, n.parent_uid, n.name, anc.depth + 1 FROM nodes n JOIN anc ON n.uid = anc.parent_uid ) SELECT name, parent_uid FROM anc ORDER BY depth DESC", )?; let rows = stmt.query_map(params![uid], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)) })?; let mut parts = Vec::new(); for r in rows { let (name, parent_uid) = r?; // Skip the root node (no parent); its name is the mount itself. if parent_uid.is_some() { parts.push(name); } } Ok(parts.join("/")) } /// Effective plaintext size of a node for the indexed `size` column: the /// claimed size when known, else the on-storage size; folders are 0. fn node_size(node: &Node) -> i64 { match &node.kind { NodeKind::Folder => 0, NodeKind::File { total_size_on_storage, claimed_size, .. } => claimed_size.unwrap_or(*total_size_on_storage).max(0), } } /// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created /// in [`Db::migrate`] before this runs. const MIGRATION_V1: &str = " CREATE TABLE nodes ( uid TEXT PRIMARY KEY, parent_uid TEXT, name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER, mtime INTEGER NOT NULL, revision_hash TEXT, trashed INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_nodes_parent ON nodes(parent_uid); CREATE VIRTUAL TABLE nodes_fts USING fts5( name, path, content='', tokenize='trigram' ); CREATE TABLE cache_entries ( cache_key TEXT PRIMARY KEY, size_bytes INTEGER, last_accessed INTEGER, is_pinned INTEGER NOT NULL DEFAULT 0 ); "; /// Schema v2: node write-through (P1). `node_json` stores the full [`Node`] so /// the hot-cache maps rehydrate losslessly; `listed` records whether a folder's /// child listing was complete when persisted. const MIGRATION_V2: &str = " ALTER TABLE nodes ADD COLUMN node_json TEXT; ALTER TABLE nodes ADD COLUMN listed INTEGER NOT NULL DEFAULT 0; "; /// Schema v3: FTS search (P3). The v1 `nodes_fts` was contentless (`content=''`) /// and never populated — drop it and recreate as a self-contained trigram index /// carrying the `uid` (UNINDEXED, so retrievable but not tokenized) alongside the /// indexed `name`. Backfill from the nodes already persisted by P1. const MIGRATION_V3: &str = " DROP TABLE IF EXISTS nodes_fts; CREATE VIRTUAL TABLE nodes_fts USING fts5( uid UNINDEXED, name, tokenize='trigram' ); INSERT INTO nodes_fts (uid, name) SELECT uid, name FROM nodes WHERE trashed = 0; "; /// Schema v4: content-cache LRU index (P4). The v1 `cache_entries` keyed only by /// `cache_key`; add a `kind` discriminator so the whole-file blob pool and the /// block pool — which carry separate byte budgets — can be summed and evicted /// independently. Existing rows (none in practice, the table was never written) /// default to `'blob'`. The daemon rebuilds the index from disk on open, so no /// backfill is needed here. const MIGRATION_V4: &str = " ALTER TABLE cache_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'blob'; "; /// Schema v5: pins move out of `pins.json` into the DB (P5). One row per /// directly-pinned node, keyed by uid display string so a pin survives /// renames/moves. `recursive` marks a folder pin whose whole subtree is kept; /// descendants are resolved on demand against `nodes` via a CTE rather than /// being expanded into rows here. `ContentCache::open` imports any legacy /// `pins.json` into this table once, then deletes the file. const MIGRATION_V5: &str = " CREATE TABLE pins ( uid TEXT PRIMARY KEY, path TEXT NOT NULL, recursive INTEGER NOT NULL DEFAULT 0 ); "; /// Schema v6: the index of *local* (non-Drive) files, so the launcher prompt can /// search the machine alongside Drive. `local_files` is keyed by absolute path; /// `scan_gen` stamps the scan that last saw a row, so a rescan prunes vanished /// files with one `DELETE` instead of diffing. /// /// `local_fts` is an *external-content* FTS5 index over `local_files.name`: the /// text lives once in the base table and the index is rebuilt in bulk at the end /// of a scan (`INSERT INTO local_fts(local_fts) VALUES('rebuild')`). That is far /// cheaper than the delete-then-insert-per-row dance `nodes_fts` needs, because /// a scan rewrites most rows at once rather than trickling single updates. const MIGRATION_V6: &str = " CREATE TABLE local_files ( id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER NOT NULL DEFAULT 0, mtime INTEGER NOT NULL DEFAULT 0, scan_gen INTEGER NOT NULL DEFAULT 0 ); CREATE VIRTUAL TABLE local_fts USING fts5( name, content='local_files', content_rowid='id', tokenize='trigram' ); "; /// Schema v7: the photos timeline and the trash listing become persistent, so /// opening the app paints them from disk instead of re-fetching the world (both /// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all). /// /// `photos.seq` preserves the server's newest-first order, which is the only order /// the timeline has — `capture_time` ties are common (a burst of shots) and would /// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally /// learned*: they cost a download to rediscover, so `photos_replace` carries them /// across a refresh while capture times and order come from the server. const MIGRATION_V7: &str = " CREATE TABLE photos ( uid TEXT PRIMARY KEY, capture_time INTEGER NOT NULL, name TEXT, ratio REAL, thumb_state INTEGER NOT NULL DEFAULT 0, seq INTEGER NOT NULL ); CREATE INDEX idx_photos_seq ON photos(seq); CREATE TABLE trash ( uid TEXT PRIMARY KEY, name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER NOT NULL DEFAULT 0, mtime INTEGER NOT NULL DEFAULT 0 ); "; /// Schema v8: device sync (devices.md). This machine registers as one Proton Drive /// **Device** (a share + root folder on the main volume); `device` is a singleton /// row cached so we reuse the same device across restarts instead of creating a /// new one each run. `sync_folder` is one row per local folder the user added, /// each mapped to a folder under the device root; `mode` is `mirror` (full local /// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local /// storage). `sync_entry` is the per-file baseline for three-way merge — added in /// this migration so Phase 2 has the table, though Phase 1 leaves it empty. const MIGRATION_V8: &str = " CREATE TABLE device ( uid TEXT PRIMARY KEY, share_id TEXT NOT NULL, root_uid TEXT NOT NULL, name TEXT NOT NULL, created INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE sync_folder ( id INTEGER PRIMARY KEY, local_path TEXT NOT NULL UNIQUE, remote_uid TEXT NOT NULL, remote_share_id TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'mirror', state TEXT NOT NULL DEFAULT 'idle', last_sync INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE sync_entry ( folder_id INTEGER NOT NULL, rel_path TEXT NOT NULL, remote_uid TEXT, local_mtime INTEGER NOT NULL DEFAULT 0, local_size INTEGER NOT NULL DEFAULT 0, remote_hash TEXT, remote_rev TEXT, PRIMARY KEY (folder_id, rel_path) ); "; const MIGRATION_V9: &str = " CREATE TABLE activity ( id INTEGER PRIMARY KEY, time INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', ok INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX activity_time ON activity(time DESC); "; /// Schema v10: a mode switch the user asked for is queued rather than rejected /// when the folder is mid-pass or has un-uploaded changes, so `pending_mode` /// records the intent until the engine can act on it. NULL is the resting state: /// the folder is already where the user wants it. const MIGRATION_V10: &str = " ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT; "; /// Schema v11: writes no longer upload inside the FUSE handler. `release` stages /// the bytes and records the intended upload here, and a drain worker performs it /// (offline.md Phase 3). The row outlives the process, so a write survives both a /// dead network and a restart. /// /// `blob_path` points into the content cache's `staging/` dir and `meta_json` is /// the [`StagedWrite`] sidecar describing which of its bytes are real — an /// incomplete blob must be gap-filled from the remote base before it can be /// uploaded. `next_attempt_at` is a ms deadline implementing retry backoff. /// /// [`StagedWrite`]: crate::cache::StagedWrite const MIGRATION_V11: &str = " CREATE TABLE pending_op ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, uid TEXT NOT NULL, blob_path TEXT, meta_json TEXT, created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, next_attempt_at INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX pending_op_uid ON pending_op(uid); "; /// Schema v12: `pending_op` also carries mutations that *create* a node, which a /// revision op never had to describe — it always addressed a node the server had /// already minted a uid for (offline.md Phase 3b). /// /// An offline `create`/`mkdir` cannot get a real uid, so the node is invented /// locally under a `local~` placeholder and the op records where it goes /// (`parent_uid`) and what it is called (`name`). Both are nullable because a /// revision op sets neither. /// /// `parent_uid` is indexed: draining a folder rewrites every queued child that /// points at its placeholder. const MIGRATION_V12: &str = " ALTER TABLE pending_op ADD COLUMN parent_uid TEXT; ALTER TABLE pending_op ADD COLUMN name TEXT; CREATE INDEX pending_op_parent ON pending_op(parent_uid); "; #[cfg(test)] mod tests { use super::*; /// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL /// settings can only be checked against a real file. #[test] fn open_bounds_the_wal_size() { let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id())); let _ = std::fs::remove_file(&path); let db = Db::open(&path).unwrap(); let conn = db.conn.lock().unwrap(); let mode: String = conn .query_row("PRAGMA journal_mode", [], |r| r.get(0)) .unwrap(); assert_eq!(mode, "wal"); // Without this the WAL is reused in place but never shrinks, so one // oversized transaction strands its high-water mark on disk forever. let limit: i64 = conn .query_row("PRAGMA journal_size_limit", [], |r| r.get(0)) .unwrap(); assert_eq!(limit, WAL_SIZE_LIMIT); drop(conn); drop(db); let _ = std::fs::remove_file(&path); } use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; fn uid(link: &str) -> NodeUid { NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) } // `NodeVerification` is not re-exported, so build test nodes by // deserializing JSON (the field has a serde default and can be omitted). fn node_from( parent: serde_json::Value, link: &str, name: &str, kind: serde_json::Value, ) -> Node { let v = serde_json::json!({ "uid": {"volume_id": "vol", "link_id": link}, "parent_uid": parent, "kind": kind, "name": name, "creation_time": 100, "modification_time": 200, "trashed": false, "signature_email": null, }); serde_json::from_value(v).unwrap() } #[test] fn photos_replace_keeps_what_was_learned_and_drops_what_left() { let db = Db::open_in_memory().unwrap(); db.photos_replace(&[ ("p1".into(), 300, None), ("p2".into(), 200, None), ("p3".into(), 100, None), ]) .unwrap(); // A thumbnail attempt teaches us p1's ratio and that p2 can never have one. db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap(); db.photo_set_thumb("p2", THUMB_NONE, None).unwrap(); // The next refresh brings a new photo, keeps p1 and p2, and loses p3. db.photos_replace(&[ ("p0".into(), 400, Some("new.jpg".into())), ("p1".into(), 300, None), ("p2".into(), 200, None), ]) .unwrap(); let page = db.photos_page(0, 10).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p0", "p1", "p2"], "server order is preserved, and the dropped photo is gone" ); assert_eq!(page[0].name.as_deref(), Some("new.jpg")); // Ratios and verdicts cost a download to rediscover: they survive a refresh. assert_eq!(page[1].ratio, Some(1.5)); assert_eq!(page[1].thumb_state, THUMB_HAVE); assert_eq!(page[2].thumb_state, THUMB_NONE); // A photo we know nothing about yet starts blank. assert_eq!(page[0].ratio, None); assert_eq!(page[0].thumb_state, THUMB_UNKNOWN); assert_eq!(db.photos_count().unwrap(), 3); let by_uid = db.photos_by_uid(&["p2".into()]).unwrap(); assert_eq!(by_uid.len(), 1); assert_eq!(by_uid[0].capture_time, 200); } #[test] fn photos_page_slices_the_timeline_in_order() { let db = Db::open_in_memory().unwrap(); let items: Vec<_> = (0..5) .map(|i| (format!("p{i}"), 500 - i as i64, None)) .collect(); db.photos_replace(&items).unwrap(); let page = db.photos_page(2, 2).unwrap(); assert_eq!( page.iter().map(|p| p.uid.as_str()).collect::>(), ["p2", "p3"] ); assert!(db.photos_page(9, 2).unwrap().is_empty()); } #[test] fn trash_replace_lists_folders_first() { let db = Db::open_in_memory().unwrap(); db.trash_replace(&[ StoredTrash { uid: "t1".into(), name: "zeta.txt".into(), is_dir: false, size: 10, mtime: 1, }, StoredTrash { uid: "t2".into(), name: "Alpha".into(), is_dir: true, size: 0, mtime: 2, }, ]) .unwrap(); let items = db.trash_list().unwrap(); assert_eq!( items.iter().map(|i| i.name.as_str()).collect::>(), ["Alpha", "zeta.txt"] ); // A replace is a replace: emptying the trash on the server empties it here. db.trash_replace(&[]).unwrap(); assert!(db.trash_list().unwrap().is_empty()); } #[test] fn state_stamps_round_trip_and_clear() { let db = Db::open_in_memory().unwrap(); assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None); db.set_state_i64("photos_synced_ms", 1234).unwrap(); assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234)); db.clear_state("photos_synced_ms").unwrap(); assert_eq!( db.state_i64("photos_synced_ms").unwrap(), None, "a cleared stamp reads as never fetched, so the next request blocks on a refresh" ); } fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { let parent = match parent { Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), None => serde_json::Value::Null, }; node_from(parent, link, name, serde_json::json!("Folder")) } fn file(link: &str, parent: &str, name: &str, size: i64) -> Node { let kind = serde_json::json!({ "File": { "media_type": "text/plain", "total_size_on_storage": size + 10, "claimed_size": size, "claimed_modification_time": null, } }); node_from( serde_json::json!({"volume_id": "vol", "link_id": parent}), link, name, kind, ) } /// Recovering the root by uid is what lets the daemon mount offline /// (offline.md Phase 1): the uid is remembered in `sync_state`, the node /// itself comes back out of `nodes`. #[test] fn node_by_uid_recovers_a_stored_node() { let db = Db::open_in_memory().unwrap(); let root = folder("root", None, "My Files"); db.upsert_node(&root).unwrap(); db.set_state_str("root_uid", &root.uid.to_string()).unwrap(); let key = db.state_str("root_uid").unwrap().unwrap(); let got = db.node_by_uid(&key).unwrap().expect("root recovered"); assert_eq!(got.uid, root.uid); assert_eq!(got.name, "My Files"); assert!(got.is_folder()); assert!(db.node_by_uid("vol~nope").unwrap().is_none()); assert!(db.state_str("never_written").unwrap().is_none()); } #[test] fn upsert_and_load_all_roundtrip() { let db = Db::open_in_memory().unwrap(); let root = folder("root", None, "My Files"); let child = file("f1", "root", "hello.txt", 42); db.upsert_node(&root).unwrap(); db.upsert_node(&child).unwrap(); let loaded = db.load_all().unwrap(); assert_eq!(loaded.len(), 2); let f = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); assert_eq!(f.node.name, "hello.txt"); assert!(!f.listed); match &f.node.kind { NodeKind::File { claimed_size, .. } => assert_eq!(*claimed_size, Some(42)), _ => panic!("expected file"), } } #[test] fn upsert_is_idempotent_update() { let db = Db::open_in_memory().unwrap(); let mut n = folder("root", None, "My Files"); db.upsert_node(&n).unwrap(); n.name = "Renamed".into(); db.upsert_node(&n).unwrap(); let loaded = db.load_all().unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].node.name, "Renamed"); } #[test] fn delete_node_removes_row() { let db = Db::open_in_memory().unwrap(); let n = folder("root", None, "My Files"); db.upsert_node(&n).unwrap(); db.delete_node(&uid("root")).unwrap(); assert!(db.load_all().unwrap().is_empty()); } #[test] fn children_if_listed_gated_on_flag() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap(); db.upsert_node(&file("f2", "root", "b.txt", 2)).unwrap(); // Not listed yet → unknown, force a re-fetch. assert!(db.children_if_listed(&uid("root")).unwrap().is_none()); db.set_listed(&uid("root"), true).unwrap(); let kids = db.children_if_listed(&uid("root")).unwrap().unwrap(); assert_eq!(kids.len(), 2); } #[test] fn children_if_listed_excludes_trashed() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); let mut trashed = file("f1", "root", "a.txt", 1); trashed.trashed = true; db.upsert_node(&trashed).unwrap(); db.set_listed(&uid("root"), true).unwrap(); assert_eq!( db.children_if_listed(&uid("root")).unwrap().unwrap().len(), 0 ); } fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { LocalEntry { path: path.into(), name: name.into(), is_dir, size: 10, mtime: 5, } } /// A local scan is searchable by substring, and a *later* scan prunes the /// paths it no longer sees — including out of the FTS index, so a deleted /// file cannot keep surfacing in the prompt. #[test] fn local_scan_indexes_then_prunes_stale_paths() { let db = Db::open_in_memory().unwrap(); let gen1 = db.local_begin_scan().unwrap(); db.local_upsert_batch( gen1, &[ local("/home/u/docs/report.pdf", "report.pdf", false), local("/home/u/docs/notes.md", "notes.md", false), ], ) .unwrap(); assert_eq!(db.local_finish_scan(gen1, 1_000).unwrap(), 2); // Trigram index gives substring (not just prefix) matches. let hits = db.search_local("port", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].path, "/home/u/docs/report.pdf"); assert!(!hits[0].is_dir); assert_eq!(db.local_indexed_at().unwrap(), Some(1_000)); // Second scan sees only notes.md → report.pdf is gone from disk. let gen2 = db.local_begin_scan().unwrap(); db.local_upsert_batch(gen2, &[local("/home/u/docs/notes.md", "notes.md", false)]) .unwrap(); assert_eq!(db.local_finish_scan(gen2, 2_000).unwrap(), 1); assert!(db.search_local("report", 10).unwrap().is_empty()); assert_eq!(db.search_local("notes", 10).unwrap().len(), 1); } /// Queries below the trigram minimum still match, via the `LIKE` fallback. #[test] fn local_search_short_query_like_fallback() { let db = Db::open_in_memory().unwrap(); let g = db.local_begin_scan().unwrap(); db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)]) .unwrap(); db.local_finish_scan(g, 1).unwrap(); assert_eq!(db.search_local("a", 10).unwrap().len(), 1); assert!(db.search_local("", 10).unwrap().is_empty()); } #[test] fn event_cursor_roundtrip() { let db = Db::open_in_memory().unwrap(); // Absent before first write → seed from server head on first mount. assert!(db.get_event_cursor().unwrap().is_none()); db.set_event_cursor("evt-1").unwrap(); assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-1")); // Overwrites, not appends. db.set_event_cursor("evt-2").unwrap(); assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-2")); } #[test] fn search_trigram_substring_and_path() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&folder("docs", Some("root"), "Documents")) .unwrap(); db.upsert_node(&file("f1", "docs", "report.pdf", 1)) .unwrap(); db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap(); db.upsert_node(&file( "f3", "root", "Rampage Open Air 2026 - order 166765244.pdf", 3, )) .unwrap(); // Substring match (trigram), not just prefix. let hits = db.search("port", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].node.name, "report.pdf"); // Path is mountpoint-relative, root excluded. assert_eq!(hits[0].path, "Documents/report.pdf"); // Top-level file → bare name. let hits = db.search("notes", 10).unwrap(); assert_eq!(hits[0].path, "notes.txt"); // Multi-term FTS5 query matching (out of order, separated terms) let hits = db.search("rampage 2026", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!( hits[0].node.name, "Rampage Open Air 2026 - order 166765244.pdf" ); } #[test] fn search_excludes_trashed_and_respects_limit() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&file("f1", "root", "alpha.txt", 1)).unwrap(); let mut gone = file("f2", "root", "alphb.txt", 1); gone.trashed = true; db.upsert_node(&gone).unwrap(); let hits = db.search("alph", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].node.name, "alpha.txt"); db.upsert_node(&file("f3", "root", "alphc.txt", 1)).unwrap(); assert_eq!(db.search("alph", 1).unwrap().len(), 1); } #[test] fn search_short_query_like_fallback() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&file("f1", "root", "ab.txt", 1)).unwrap(); // Under trigram min length → LIKE path still finds it. let hits = db.search("ab", 10).unwrap(); assert_eq!(hits.len(), 1); assert_eq!(hits[0].node.name, "ab.txt"); } #[test] fn search_drops_fts_row_on_delete_and_trash() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&file("f1", "root", "unique.txt", 1)) .unwrap(); assert_eq!(db.search("unique", 10).unwrap().len(), 1); // Re-upsert as trashed → leaves the index. let mut t = file("f1", "root", "unique.txt", 1); t.trashed = true; db.upsert_node(&t).unwrap(); assert_eq!(db.search("unique", 10).unwrap().len(), 0); // Resurrect, then hard-delete. db.upsert_node(&file("f1", "root", "unique.txt", 1)) .unwrap(); assert_eq!(db.search("unique", 10).unwrap().len(), 1); db.delete_node(&uid("f1")).unwrap(); assert_eq!(db.search("unique", 10).unwrap().len(), 0); } fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry { ActivityEntry { time: 1700, kind, target: target.into(), detail: "detail".into(), ok, } } #[test] fn activity_reads_back_newest_first() { let db = Db::open_in_memory().unwrap(); db.activity_add(&activity("a.txt", ActivityKind::Upload, true)) .unwrap(); db.activity_add(&activity("b.txt", ActivityKind::Download, false)) .unwrap(); let items = db.activity_list(10).unwrap(); assert_eq!(items.len(), 2); assert_eq!(items[0].target, "b.txt"); assert_eq!(items[0].kind, ActivityKind::Download); assert!(!items[0].ok); assert_eq!(items[0].detail, "detail"); assert_eq!(items[0].time, 1700); assert_eq!(items[1].target, "a.txt"); assert_eq!(db.activity_list(1).unwrap().len(), 1); } #[test] fn activity_prunes_to_the_keep_limit() { let db = Db::open_in_memory().unwrap(); for i in 0..(ACTIVITY_KEEP + 10) { db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true)) .unwrap(); } let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap(); assert_eq!(items.len(), ACTIVITY_KEEP as usize); // The newest survive; the oldest are the ones dropped. assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9)); } #[test] fn opens_and_migrates() { let db = Db::open_in_memory().unwrap(); let version: String = db .with_conn(|c| { Ok(c.query_row( "SELECT value FROM sync_state WHERE key = 'schema_version'", [], |r| r.get(0), )?) }) .unwrap(); assert_eq!(version, SCHEMA_VERSION.to_string()); } #[test] fn a_second_write_supersedes_the_first_pending_op() { let db = Db::open_in_memory().unwrap(); let op = |blob: &str| PendingOp { id: 0, kind: OP_REVISION.to_string(), uid: uid("a").to_string(), parent_uid: None, name: None, blob_path: Some(blob.to_string()), meta_json: Some("{}".to_string()), created_at: 1, attempts: 0, last_error: None, next_attempt_at: 0, }; let (_, superseded) = db.enqueue_op(&op("/staging/first")).unwrap(); assert_eq!(superseded, None, "nothing to supersede on the first write"); // The newer blob already contains everything the older one did, so the // older op must go — and its blob must be reported so it can be deleted // rather than leaked. let (id2, superseded) = db.enqueue_op(&op("/staging/second")).unwrap(); assert_eq!(superseded.as_deref(), Some("/staging/first")); let ops = db.pending_ops().unwrap(); assert_eq!(ops.len(), 1, "one queued upload per node"); assert_eq!(ops[0].id, id2); assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); assert_eq!(db.pending_op_count().unwrap(), 1); } #[test] fn a_write_folds_into_a_queued_create_instead_of_superseding_it() { let db = Db::open_in_memory().unwrap(); let local = "local~abc"; db.enqueue_op(&PendingOp { id: 0, kind: OP_CREATE.to_string(), uid: local.to_string(), parent_uid: Some(uid("parent").to_string()), name: Some("new.txt".to_string()), blob_path: None, meta_json: None, created_at: 1, attempts: 0, last_error: None, next_attempt_at: 0, }) .unwrap(); let first = db .attach_blob_to_create(local, "/staging/first", "{}") .unwrap() .expect("create is still queued"); assert_eq!(first.superseded, None); // Rewriting the file before it drains replaces the bytes but must leave // the create itself alone: it is the only thing that will ever bring this // file into existence remotely. let second = db .attach_blob_to_create(local, "/staging/second", "{}") .unwrap() .expect("create is still queued"); assert_eq!(second.superseded.as_deref(), Some("/staging/first")); let ops = db.pending_ops().unwrap(); assert_eq!(ops.len(), 1, "still exactly one create"); assert_eq!(ops[0].kind, OP_CREATE); assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); assert_eq!(ops[0].name.as_deref(), Some("new.txt")); } #[test] fn attaching_to_an_already_drained_create_reports_it_is_gone() { let db = Db::open_in_memory().unwrap(); // No create queued: the caller must fall back to a revision op rather // than silently dropping the bytes. let out = db .attach_blob_to_create("local~gone", "/staging/x", "{}") .unwrap(); assert!(out.is_none()); } #[test] fn draining_a_folder_repoints_its_queued_children() { let db = Db::open_in_memory().unwrap(); let local_dir = "local~dir"; let real_dir = uid("realdir").to_string(); db.enqueue_op(&PendingOp { id: 0, kind: OP_CREATE.to_string(), uid: "local~child".to_string(), parent_uid: Some(local_dir.to_string()), name: Some("inside.txt".to_string()), blob_path: Some("/staging/child".to_string()), meta_json: Some("{}".to_string()), created_at: 2, attempts: 0, last_error: None, next_attempt_at: 0, }) .unwrap(); db.remap_local_uid(local_dir, &real_dir).unwrap(); // The child was queued against a folder that did not exist yet. Once the // folder is real, the child must target the server's uid — otherwise the // upload would address `local~dir` and 404. let ops = db.pending_ops().unwrap(); assert_eq!(ops[0].parent_uid.as_deref(), Some(real_dir.as_str())); } #[test] fn a_failed_op_stays_queued_with_backoff() { let db = Db::open_in_memory().unwrap(); db.enqueue_op(&PendingOp { id: 0, kind: OP_REVISION.to_string(), uid: uid("a").to_string(), parent_uid: None, name: None, blob_path: Some("/staging/blob".to_string()), meta_json: Some("{}".to_string()), created_at: 1, attempts: 0, last_error: None, next_attempt_at: 0, }) .unwrap(); let id = db.pending_ops().unwrap()[0].id; db.record_op_failure(id, "network unreachable", 5_000) .unwrap(); // The staged blob is the only copy of the user's bytes: a failure must // never drop the row, only defer it. let ops = db.pending_ops().unwrap(); assert_eq!(ops.len(), 1); assert_eq!(ops[0].attempts, 1); assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable")); assert_eq!(ops[0].next_attempt_at, 5_000); db.record_op_failure(id, "still down", 9_000).unwrap(); assert_eq!(db.pending_ops().unwrap()[0].attempts, 2); db.delete_op(id).unwrap(); assert_eq!(db.pending_op_count().unwrap(), 0); } #[test] fn migrate_is_idempotent() { let db = Db::open_in_memory().unwrap(); // Second migrate is a no-op (already at head) and must not error. db.migrate().unwrap(); } /// A queued mode switch is a promise the daemon has to keep across a restart, /// so it lives in the row, and reaching the mode is what retires it. #[test] fn pending_mode_is_queued_until_the_mode_is_reached() { let db = Db::open_in_memory().unwrap(); let id = db .sync_folder_add("/home/me/Downloads", "v~l", "s") .unwrap(); assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None); db.sync_folder_set_pending_mode(id, Some("ondemand")) .unwrap(); assert_eq!( db.sync_folder_get(id) .unwrap() .unwrap() .pending_mode .as_deref(), Some("ondemand") ); // The listing carries it too — it is what the front-ends paint from. assert_eq!( db.sync_folder_list().unwrap()[0].pending_mode.as_deref(), Some("ondemand") ); // Landing the switch satisfies the request: a `pending_mode` outliving it // would have the engine try to apply the same switch on every later pass. db.sync_folder_set_mode(id, "ondemand").unwrap(); let folder = db.sync_folder_get(id).unwrap().unwrap(); assert_eq!(folder.mode, "ondemand"); assert_eq!(folder.pending_mode, None); // And the user can withdraw a request that hasn't landed yet. db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap(); db.sync_folder_set_pending_mode(id, None).unwrap(); let folder = db.sync_folder_get(id).unwrap().unwrap(); assert_eq!(folder.mode, "ondemand"); assert_eq!(folder.pending_mode, None); } #[test] fn cache_index_touch_access_and_lru_order() { let db = Db::open_in_memory().unwrap(); db.cache_touch("k1", "blob", 100, 10).unwrap(); db.cache_touch("k2", "blob", 200, 20).unwrap(); // LRU-first: k1 (older access) before k2. let rows = db.cache_entries_by_kind("blob").unwrap(); assert_eq!(rows, vec![("k1".into(), 100), ("k2".into(), 200)]); // Accessing k1 moves it to the back (most recent). db.cache_accessed("k1", 30).unwrap(); let rows = db.cache_entries_by_kind("blob").unwrap(); assert_eq!(rows[0].0, "k2"); assert_eq!(rows[1].0, "k1"); // Re-touch updates size, not just time. db.cache_touch("k1", "blob", 150, 40).unwrap(); let rows = db.cache_entries_by_kind("blob").unwrap(); assert_eq!(rows.iter().find(|(k, _)| k == "k1").unwrap().1, 150); } #[test] fn cache_index_kinds_are_separate() { let db = Db::open_in_memory().unwrap(); db.cache_touch("blob1", "blob", 100, 1).unwrap(); db.cache_touch("blk1.b0", "block", 50, 1).unwrap(); assert_eq!(db.cache_entries_by_kind("blob").unwrap().len(), 1); assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); } #[test] fn cache_index_remove_and_remove_all() { let db = Db::open_in_memory().unwrap(); // A blob plus two of its blocks (key prefix shared). db.cache_touch("abc", "blob", 1, 1).unwrap(); db.cache_touch("abc.b0", "block", 1, 1).unwrap(); db.cache_touch("abc.b1", "block", 1, 1).unwrap(); // An unrelated entry that must survive. db.cache_touch("xyz", "blob", 1, 1).unwrap(); db.cache_remove("abc.b0").unwrap(); assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); // remove_all drops the blob row and every remaining block of that uid. db.cache_remove_all("abc").unwrap(); assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); let blobs = db.cache_entries_by_kind("blob").unwrap(); assert_eq!(blobs, vec![("xyz".into(), 1)]); } #[test] fn cache_index_clear() { let db = Db::open_in_memory().unwrap(); db.cache_touch("k1", "blob", 1, 1).unwrap(); db.cache_touch("k2", "block", 1, 1).unwrap(); db.cache_clear().unwrap(); assert!(db.cache_entries_by_kind("blob").unwrap().is_empty()); assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); } #[test] fn pin_add_list_remove_roundtrip() { let db = Db::open_in_memory().unwrap(); db.pin_add("vol~a", "docs/a.txt", false).unwrap(); db.pin_add("vol~d", "docs", true).unwrap(); let list = db.pin_list().unwrap(); assert_eq!(list.len(), 2); assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false)); assert_eq!(list[1], ("vol~d".into(), "docs".into(), true)); // Re-pin refreshes path/flag, not a duplicate row. db.pin_add("vol~a", "moved/a.txt", false).unwrap(); let list = db.pin_list().unwrap(); assert_eq!(list.len(), 2); assert_eq!(list[0].1, "moved/a.txt"); assert!(db.pin_remove("vol~a").unwrap()); assert!(!db.pin_remove("vol~a").unwrap()); assert_eq!(db.pin_list().unwrap().len(), 1); } #[test] fn is_pinned_direct_without_node_row() { // A direct pin counts even when the node was never hydrated into `nodes`. let db = Db::open_in_memory().unwrap(); db.pin_add("vol~a", "a.txt", false).unwrap(); assert!(db.is_pinned("vol~a").unwrap()); assert!(!db.is_pinned("vol~b").unwrap()); } #[test] fn recursive_folder_pin_covers_subtree() { let db = Db::open_in_memory().unwrap(); // root/docs/{report.pdf, sub/deep.txt}, root/loose.txt db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&folder("docs", Some("root"), "Documents")) .unwrap(); db.upsert_node(&file("rep", "docs", "report.pdf", 1)) .unwrap(); db.upsert_node(&folder("sub", Some("docs"), "Sub")).unwrap(); db.upsert_node(&file("deep", "sub", "deep.txt", 1)).unwrap(); db.upsert_node(&file("loose", "root", "loose.txt", 1)) .unwrap(); // Pin the Documents folder recursively (uids are `vol~link` display form). let du = |l: &str| uid(l).to_string(); db.pin_add(&du("docs"), "Documents", true).unwrap(); // Everything under docs (any depth) is pinned; loose.txt is not. assert!(db.is_pinned(&du("docs")).unwrap()); assert!(db.is_pinned(&du("rep")).unwrap()); assert!(db.is_pinned(&du("deep")).unwrap()); assert!(!db.is_pinned(&du("loose")).unwrap()); // pinned_uids expands the subtree (folder + descendants), no loose.txt. let mut got = db.pinned_uids().unwrap(); got.sort(); assert_eq!(got, vec![du("deep"), du("docs"), du("rep"), du("sub")]); // descendants() lists the subtree of a folder (excludes the folder). let mut desc = db.descendants(&du("docs")).unwrap(); desc.sort(); assert_eq!(desc, vec![du("deep"), du("rep"), du("sub")]); } #[test] fn non_recursive_folder_pin_does_not_cover_children() { let db = Db::open_in_memory().unwrap(); db.upsert_node(&folder("root", None, "My Files")).unwrap(); db.upsert_node(&folder("docs", Some("root"), "Documents")) .unwrap(); db.upsert_node(&file("rep", "docs", "report.pdf", 1)) .unwrap(); // A non-recursive pin on the folder covers only the folder itself. let du = |l: &str| uid(l).to_string(); db.pin_add(&du("docs"), "Documents", false).unwrap(); assert!(db.is_pinned(&du("docs")).unwrap()); assert!(!db.is_pinned(&du("rep")).unwrap()); } #[test] fn schema_objects_exist() { let db = Db::open_in_memory().unwrap(); let count: i64 = db .with_conn(|c| { Ok(c.query_row( "SELECT count(*) FROM sqlite_master WHERE name IN ('nodes', 'nodes_fts', 'cache_entries')", [], |r| r.get(0), )?) }) .unwrap(); assert_eq!(count, 3); } }