mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
2694 lines
102 KiB
Rust
2694 lines
102 KiB
Rust
//! 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~<uuid>` 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~<uuid>` 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<String>,
|
|
/// The new node's name; only set for [`OP_CREATE`]/[`OP_MKDIR`].
|
|
pub name: Option<String>,
|
|
/// Staged blob holding the bytes to upload.
|
|
pub blob_path: Option<String>,
|
|
/// Serialized [`StagedWrite`](crate::cache::StagedWrite).
|
|
pub meta_json: Option<String>,
|
|
/// When the op was queued (ms since epoch).
|
|
pub created_at: i64,
|
|
pub attempts: i64,
|
|
pub last_error: Option<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
|
|
pub ratio: Option<f64>,
|
|
/// 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<String>,
|
|
/// `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<String>,
|
|
pub local_mtime: i64,
|
|
pub local_size: i64,
|
|
pub remote_rev: Option<String>,
|
|
pub remote_hash: Option<String>,
|
|
}
|
|
|
|
/// Handle to the unified metadata database.
|
|
///
|
|
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
|
/// the inner `Mutex<Connection>`.
|
|
pub struct Db {
|
|
conn: Mutex<Connection>,
|
|
}
|
|
|
|
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<Self> {
|
|
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<Self> {
|
|
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<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
|
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<Vec<SearchHit>> {
|
|
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::<Vec<_>>()
|
|
.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<Vec<StoredNode>> {
|
|
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<Option<Node>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let json: Option<String> = 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<Option<String>> {
|
|
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<Option<String>> {
|
|
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<String>)> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let superseded: Option<String> = 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<Option<AttachedBlob>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let existing: Option<(i64, Option<String>)> = 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<Vec<String>> {
|
|
let mut conn = self.conn.lock().unwrap();
|
|
let tx = conn.transaction()?;
|
|
let blobs: Vec<String> = {
|
|
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::<std::result::Result<Vec<_>, _>>()?
|
|
};
|
|
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<Vec<PendingOp>> {
|
|
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::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// How many uploads are still waiting, for `Response::Status`.
|
|
pub fn pending_op_count(&self) -> Result<i64> {
|
|
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<Option<i64>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let v: Option<String> = 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<String>)]) -> Result<()> {
|
|
let mut conn = self.conn.lock().unwrap();
|
|
let tx = conn.transaction()?;
|
|
let learned: HashMap<String, (Option<f64>, 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::<rusqlite::Result<_>>()?
|
|
};
|
|
|
|
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<Vec<StoredPhoto>> {
|
|
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<Vec<StoredPhoto>> {
|
|
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<f64>) -> 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<usize> {
|
|
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<Vec<StoredTrash>> {
|
|
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<Option<StoredDevice>> {
|
|
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<i64> {
|
|
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<Vec<StoredSyncFolder>> {
|
|
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<Option<StoredSyncFolder>> {
|
|
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<bool> {
|
|
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<HashMap<String, StoredSyncEntry>> {
|
|
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<Vec<ActivityEntry>> {
|
|
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::<ActivityKind>(&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<Option<Vec<Node>>> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let listed: Option<i64> = 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 (`<key>.b<idx>`). 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<Vec<(String, u64)>> {
|
|
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<bool> {
|
|
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<Vec<(String, String, bool)>> {
|
|
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<bool> {
|
|
let conn = self.conn.lock().unwrap();
|
|
let direct: Option<i64> = 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<i64> = 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<Vec<String>> {
|
|
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<Vec<String>> {
|
|
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<i64> {
|
|
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<i64> {
|
|
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<Option<i64>> {
|
|
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<Vec<LocalFileHit>> {
|
|
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::<Vec<_>>()
|
|
.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<LocalFileHit> {
|
|
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<Item = rusqlite::Result<(String, String)>>,
|
|
) -> Result<Vec<(String, String)>> {
|
|
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<String> {
|
|
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<String>>(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~<uuid>` 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::<Vec<_>>(),
|
|
["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::<Vec<_>>(),
|
|
["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::<Vec<_>>(),
|
|
["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);
|
|
}
|
|
}
|