Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/trachyte-db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
rusqlite = { workspace = true }
blake3 = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
11 changes: 11 additions & 0 deletions crates/trachyte-db/src/error.rs
Original file line number Diff line number Diff line change
@@ -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),
}
66 changes: 66 additions & 0 deletions crates/trachyte-db/src/fts.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>, 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()]);
}
}
24 changes: 24 additions & 0 deletions crates/trachyte-db/src/hash.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
}
103 changes: 101 additions & 2 deletions crates/trachyte-db/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Path>) -> Result<Database, DbError> {
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<i64, DbError> {
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<Vec<String>, 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());
}
}
73 changes: 73 additions & 0 deletions crates/trachyte-db/src/migrations.rs
Original file line number Diff line number Diff line change
@@ -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<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'index')")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.collect::<Result<Vec<String>, _>>()
.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);
}
}
42 changes: 42 additions & 0 deletions crates/trachyte-db/src/pragmas.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
36 changes: 36 additions & 0 deletions crates/trachyte-db/src/schema.rs
Original file line number Diff line number Diff line change
@@ -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);
";