diff --git a/CHANGELOG.md b/CHANGELOG.md index e38204d..cb7ba51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Vault path resolution: `home_dir` IPC command (`dirs`) + `resolveHomePath` in core — `~/` and relative vault paths now resolve to absolute paths anywhere on disk - `trachyte doctor` dev-tools: `doctor` IPC command (vault validity + missing dirs/files + `schema_version` value), core aggregator (`buildDoctorReport` / `settingsValidity` reusing `isSettings`), and a `DoctorPanel` opened via the sidebar "Run doctor" button or `Ctrl+Shift+D` +- `trachyte-db` SQLite bootstrap: `Database` wrapper (open → pragmas → migrate) with `PRAGMA journal_mode=WAL`/`synchronous=NORMAL`/`foreign_keys=ON`; schema v1 (`files`/`headings`/`tags`/`backlinks` with `ON DELETE CASCADE`); FTS5 mirror table `content_fts` (porter tokenizer) + `search`; BLAKE3 `hash_content`; versioned migration runner (`PRAGMA user_version`, one tx per migration) + ### Changed - CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility diff --git a/Cargo.lock b/Cargo.lock index fcbc345..2b58768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3676,6 +3676,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", ] diff --git a/crates/trachyte-db/Cargo.toml b/crates/trachyte-db/Cargo.toml index 4640b30..0667d3d 100644 --- a/crates/trachyte-db/Cargo.toml +++ b/crates/trachyte-db/Cargo.toml @@ -10,3 +10,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } rusqlite = { workspace = true } blake3 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/trachyte-db/src/error.rs b/crates/trachyte-db/src/error.rs new file mode 100644 index 0000000..36c41ee --- /dev/null +++ b/crates/trachyte-db/src/error.rs @@ -0,0 +1,11 @@ +//! Database error type for the SQLite index crate. + +use thiserror::Error; + +/// Errors that can occur during database operations. +#[derive(Debug, Error)] +pub enum DbError { + /// Wrapped SQLite error. + #[error("sqlite: {0}")] + Sql(#[from] rusqlite::Error), +} diff --git a/crates/trachyte-db/src/fts.rs b/crates/trachyte-db/src/fts.rs index 9947f48..501170b 100644 --- a/crates/trachyte-db/src/fts.rs +++ b/crates/trachyte-db/src/fts.rs @@ -1 +1,67 @@ //! FTS5 virtual table operations. + +use rusqlite::{params, Connection}; + +use crate::DbError; + +/// FTS5 virtual table mirroring file contents for full-text search. +pub const FTS5_SCHEMA: &str = " +CREATE VIRTUAL TABLE content_fts USING fts5( + path UNINDEXED, + content, + tokenize = 'porter' +); +"; + +/// Insert the FTS mirror row for a file. +pub fn insert_content(conn: &Connection, path: &str, content: &str) -> Result<(), DbError> { + conn.execute( + "INSERT INTO content_fts(path, content) VALUES (?1, ?2)", + params![path, content], + )?; + Ok(()) +} + +/// Full-text search over file contents; returns matching paths. +pub fn search(conn: &Connection, query: &str) -> Result, DbError> { + let mut stmt = + conn.prepare("SELECT path FROM content_fts WHERE content_fts MATCH ?1 ORDER BY rank")?; + let rows = stmt.query_map(params![query], |row| row.get(0))?; + let mut paths = Vec::new(); + for row in rows { + paths.push(row?); + } + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn setup() -> (tempfile::TempDir, rusqlite::Connection) { + let dir = tempdir().unwrap(); + let conn = rusqlite::Connection::open(dir.path().join("test.db")).unwrap(); + conn.execute_batch(FTS5_SCHEMA).unwrap(); + (dir, conn) + } + + #[test] + fn search_returns_matching_paths() { + let (_dir, conn) = setup(); + insert_content( + &conn, + "Notes/Java.md", + "Object-oriented programming language", + ) + .unwrap(); + insert_content(&conn, "Notes/Rust.md", "Systems programming language").unwrap(); + + let hits = search(&conn, "programming").unwrap(); + assert!(hits.contains(&"Notes/Java.md".to_string())); + assert!(hits.contains(&"Notes/Rust.md".to_string())); + + let rust = search(&conn, "systems").unwrap(); + assert_eq!(rust, vec!["Notes/Rust.md".to_string()]); + } +} diff --git a/crates/trachyte-db/src/hash.rs b/crates/trachyte-db/src/hash.rs index 09db059..e5a000d 100644 --- a/crates/trachyte-db/src/hash.rs +++ b/crates/trachyte-db/src/hash.rs @@ -1 +1,25 @@ //! BLAKE3 content hashing. + +/// Hash content bytes with BLAKE3 and return the hex digest. +pub fn hash_content(bytes: impl AsRef<[u8]>) -> String { + blake3::hash(bytes.as_ref()).to_hex().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_content_known_value() { + assert_eq!( + hash_content(b"hello world"), + "d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24" + ); + } + + #[test] + fn hash_is_stable_and_sensitive() { + assert_eq!(hash_content(b"abc"), hash_content(b"abc")); + assert_ne!(hash_content(b"abc"), hash_content(b"abd")); + } +} diff --git a/crates/trachyte-db/src/lib.rs b/crates/trachyte-db/src/lib.rs index 313059e..cc45adb 100644 --- a/crates/trachyte-db/src/lib.rs +++ b/crates/trachyte-db/src/lib.rs @@ -1,9 +1,108 @@ -#![deny(missing_docs)] - //! SQLite index with FTS5 search (WAL mode). +use std::path::Path; + +use rusqlite::Connection; + +pub mod error; pub mod fts; pub mod hash; pub mod migrations; pub mod pragmas; pub mod schema; + +pub use error::DbError; + +/// A SQLite index database with WAL + FTS5 configured. +pub struct Database { + conn: Connection, +} + +impl Database { + /// Open (or create) an index database at `path`, applying pragmas and + /// running pending migrations. + /// + /// The parent directory must already exist (the vault's `.trachyte/` + /// guarantees this); it is not created. + pub fn open(path: impl AsRef) -> Result { + let mut conn = Connection::open(path)?; + pragmas::apply_pragmas(&conn)?; + migrations::migrate(&mut conn)?; + Ok(Database { conn }) + } + + /// Insert a file and its content into the index, returning its row id. + /// + /// `hash` should be the BLAKE3 hex digest of `content` + /// (see [`hash::hash_content`]); the caller computes `size`. + pub fn insert_file( + &self, + path: &str, + content: &str, + mtime: i64, + size: i64, + hash: &str, + ) -> Result { + self.conn.execute( + "INSERT INTO files (path, mtime, size, hash) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![path, mtime, size, hash], + )?; + let id = self.conn.last_insert_rowid(); + fts::insert_content(&self.conn, path, content)?; + Ok(id) + } + + /// Full-text search over file contents; returns matching paths. + pub fn search(&self, query: &str) -> Result, DbError> { + fts::search(&self.conn, query) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn open_db() -> (tempfile::TempDir, Database) { + let dir = tempdir().unwrap(); + let db = Database::open(dir.path().join("index.db")).unwrap(); + (dir, db) + } + + #[test] + fn insert_and_search_roundtrip() { + let (_dir, db) = open_db(); + let content = "# Hello\n\nThis is my note about databases."; + db.insert_file( + "Notes/Hello.md", + content, + 1_700_000_000, + content.len() as i64, + &hash::hash_content(content), + ) + .unwrap(); + + let hits = db.search("databases").unwrap(); + assert_eq!(hits, vec!["Notes/Hello.md".to_string()]); + } + + #[test] + fn search_misses_for_unknown_term() { + let (_dir, db) = open_db(); + db.insert_file("Notes/A.md", "alpha beta", 0, 10, "deadbeef") + .unwrap(); + + assert!(db.search("omega").unwrap().is_empty()); + } + + #[test] + fn wal_file_present_after_first_write() { + let dir = tempdir().unwrap(); + let db = Database::open(dir.path().join("index.db")).unwrap(); + + db.insert_file("Notes/A.md", "wal test", 0, 8, "deadbeef") + .unwrap(); + + assert!(dir.path().join("index.db-wal").exists()); + } +} diff --git a/crates/trachyte-db/src/migrations.rs b/crates/trachyte-db/src/migrations.rs index 29b68ad..38424a6 100644 --- a/crates/trachyte-db/src/migrations.rs +++ b/crates/trachyte-db/src/migrations.rs @@ -1 +1,74 @@ //! Migration runner. + +use rusqlite::Connection; + +use crate::fts; +use crate::schema; +use crate::DbError; + +/// Run any unapplied migrations, each inside its own transaction +/// Version 1 creates the relational tables (`schema::SCHEMA_V1`) and the +/// FTS5 mirror (`fts::FTS5_SCHEMA`) together, bumping `user_version` to 1 +pub fn migrate(conn: &mut Connection) -> Result<(), DbError> { + let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if version < 1 { + let tx = conn.transaction()?; + tx.execute_batch(schema::SCHEMA_V1)?; + tx.execute_batch(fts::FTS5_SCHEMA)?; + tx.pragma_update(None, "user_version", 1)?; + tx.commit()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn migrate_creates_schema_and_sets_version() { + let dir = tempdir().unwrap(); + let mut conn = rusqlite::Connection::open(dir.path().join("test.db")).unwrap(); + + migrate(&mut conn).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 1); + + let objects: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index')") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + for expected in [ + "files", + "headings", + "tags", + "backlinks", + "content_fts", + "idx_tags_tag", + "idx_backlinks_target", + ] { + assert!(objects.iter().any(|o| o == expected), "missing {expected}"); + } + } + + #[test] + fn migrate_is_idempotent() { + let dir = tempdir().unwrap(); + let mut conn = rusqlite::Connection::open(dir.path().join("test.db")).unwrap(); + + migrate(&mut conn).unwrap(); + migrate(&mut conn).unwrap(); + + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 1); + } +} diff --git a/crates/trachyte-db/src/pragmas.rs b/crates/trachyte-db/src/pragmas.rs index 76963be..da53b70 100644 --- a/crates/trachyte-db/src/pragmas.rs +++ b/crates/trachyte-db/src/pragmas.rs @@ -1 +1,43 @@ //! WAL + foreign_keys + synchronous pragmas. + +use rusqlite::Connection; + +use crate::DbError; + +/// Apply the connection-level pragmas required by the vault contract: +/// WAL journaling, `synchronous=NORMAL`, and foreign key enforcement +/// Must run before any transaction (WAL mode cannot be enabled inside one) +pub fn apply_pragmas(conn: &Connection) -> Result<(), DbError> { + conn.pragma_update(None, "journal_mode", "wal")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn open_applies_pragmas() { + let dir = tempdir().unwrap(); + let conn = rusqlite::Connection::open(dir.path().join("test.db")).unwrap(); + + apply_pragmas(&conn).unwrap(); + + let journal_mode: String = conn + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap(); + let synchronous: i64 = conn + .pragma_query_value(None, "synchronous", |row| row.get(0)) + .unwrap(); + let foreign_keys: i64 = conn + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .unwrap(); + + assert_eq!(journal_mode, "wal"); + assert_eq!(synchronous, 1); + assert_eq!(foreign_keys, 1); + } +} diff --git a/crates/trachyte-db/src/schema.rs b/crates/trachyte-db/src/schema.rs index e6296ea..14febf7 100644 --- a/crates/trachyte-db/src/schema.rs +++ b/crates/trachyte-db/src/schema.rs @@ -1 +1,37 @@ //! Schema definitions. + +/// Schema version 1 for the project +/// The FTS5 mirror lives in [`super::fts::FTS5_SCHEMA`]; migrations apply +/// both together. `files` is the parent of every other table; +/// deleting a file cascades to its headings, tags, and backlinks +pub const SCHEMA_V1: &str = " +CREATE TABLE files ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL, + hash TEXT NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE headings ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + level INTEGER NOT NULL, + text TEXT NOT NULL, + position INTEGER NOT NULL +); + +CREATE TABLE tags ( + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + tag TEXT NOT NULL +); + +CREATE TABLE backlinks ( + target_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + source_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + position_char INTEGER NOT NULL +); + +CREATE INDEX idx_tags_tag ON tags(tag); +CREATE INDEX idx_backlinks_target ON backlinks(target_file_id); +";