Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `trachyte-db` IPC exposure: `index_search` / `index_insert_file` commands (per-call open of `<vault>/.trachyte/index.db`), `IpcError::InvalidQuery` for malformed FTS5 queries, `IpcError::Db`, typed `ipc/db.ts` wrapper

- Event-driven `Indexer` in core (upsert/delete/rename on vault events + startup sweep that converges missed events); `IndexDriver` seam + `MemoryIndexDriver` (test default) and `tauriIndexDriver` in the app; `extractIndexContent` frontmatter strip; `IndexedFileMeta`/`IndexEvent` types; write commands `index_upsert_file` / `index_delete_file` / `index_list_files` + typed `ipc/db.ts` wrappers

### Changed

- CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub fn run() {
trachyte_ipc::commands::doctor,
trachyte_ipc::commands::index_search,
trachyte_ipc::commands::index_insert_file,
trachyte_ipc::commands::index_upsert_file,
trachyte_ipc::commands::index_delete_file,
trachyte_ipc::commands::index_list_files,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/ipc/db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { IndexedFileMeta } from "@trachyte/core";

export async function indexInsertFile(
vaultPath: string,
Expand All @@ -12,3 +13,20 @@ export async function indexInsertFile(
export async function indexSearch(vaultPath: string, q: string): Promise<string[]> {
return invoke<string[]>("index_search", { vaultPath, q });
}

export async function indexUpsertFile(
vaultPath: string,
relPath: string,
content: string,
mtime: number,
): Promise<number> {
return invoke<number>("index_upsert_file", { vaultPath, relPath, content, mtime });
}

export async function indexDeleteFile(vaultPath: string, relPath: string): Promise<void> {
return invoke<void>("index_delete_file", { vaultPath, relPath });
}

export async function indexListFiles(vaultPath: string): Promise<IndexedFileMeta[]> {
return invoke<IndexedFileMeta[]>("index_list_files", { vaultPath });
}
8 changes: 8 additions & 0 deletions apps/desktop/src/ipc/index-driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { IndexDriver } from "@trachyte/core";
import { indexDeleteFile, indexListFiles, indexUpsertFile } from "./db.js";

export const tauriIndexDriver: IndexDriver = {
upsertFile: indexUpsertFile,
deleteFile: indexDeleteFile,
listFiles: indexListFiles,
};
114 changes: 114 additions & 0 deletions crates/trachyte-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ pub mod schema;
pub use error::DbError;
pub use hash::hash_content;

/// Metadata for a file row in the index.
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileMeta {
/// Vault-relative path of the indexed file.
pub path: String,
/// Last-modified timestamp as supplied at insert time.
pub mtime: i64,
/// Content size in bytes.
pub size: i64,
/// BLAKE3 hex digest of the content.
pub hash: String,
}

/// A SQLite index database with WAL + FTS5 configured.
pub struct Database {
conn: Connection,
Expand Down Expand Up @@ -53,6 +66,63 @@ impl Database {
Ok(id)
}

/// Insert a file into the index, or update it if the path already exists
/// Refreshes the FTS mirror so search reflects the latest content
pub fn upsert_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)
ON CONFLICT(path) DO UPDATE SET
mtime = excluded.mtime, size = excluded.size, hash = excluded.hash",
rusqlite::params![path, mtime, size, hash],
)?;
self.conn.execute(
"DELETE FROM content_fts WHERE path = ?1",
rusqlite::params![path],
)?;
fts::insert_content(&self.conn, path, content)?;
let id = self.conn.query_row(
"SELECT id FROM files WHERE path = ?1",
rusqlite::params![path],
|row| row.get(0),
)?;
Ok(id)
}

/// Remove a file and its FTS mirror row from the index.
/// Headings, tags, and backlinks are removed by `ON DELETE CASCADE`.
pub fn delete_file(&self, path: &str) -> Result<(), DbError> {
self.conn.execute(
"DELETE FROM content_fts WHERE path = ?1",
rusqlite::params![path],
)?;
self.conn
.execute("DELETE FROM files WHERE path = ?1", rusqlite::params![path])?;
Ok(())
}

/// List all indexed files with their metadata, ordered by path.
pub fn list_meta(&self) -> Result<Vec<FileMeta>, DbError> {
let mut stmt = self
.conn
.prepare("SELECT path, mtime, size, hash FROM files ORDER BY path")?;
let rows = stmt.query_map([], |row| {
Ok(FileMeta {
path: row.get(0)?,
mtime: row.get(1)?,
size: row.get(2)?,
hash: row.get(3)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(DbError::from)
}

/// Full-text search over file contents; returns matching paths.
pub fn search(&self, query: &str) -> Result<Vec<String>, DbError> {
fts::search(&self.conn, query)
Expand Down Expand Up @@ -106,4 +176,48 @@ mod tests {

assert!(dir.path().join("index.db-wal").exists());
}

#[test]
fn upsert_updates_existing_row() {
let (_dir, db) = open_db();
let id1 = db
.upsert_file("Notes/A.md", "first version", 1, 13, "hash-1")
.unwrap();
let id2 = db
.upsert_file("Notes/A.md", "second version", 2, 15, "hash-2")
.unwrap();

assert_eq!(id1, id2, "upsert keeps the same row id");
let hits = db.search("second").unwrap();
assert_eq!(hits, vec!["Notes/A.md".to_string()]);
assert!(
db.search("first").unwrap().is_empty(),
"FTS row was refreshed"
);
}

#[test]
fn delete_removes_file_and_fts_row() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "hello world", 0, 11, "deadbeef")
.unwrap();
assert_eq!(db.search("hello").unwrap().len(), 1);

db.delete_file("Notes/A.md").unwrap();

assert!(db.search("hello").unwrap().is_empty());
assert!(db.list_meta().unwrap().is_empty());
}

#[test]
fn list_meta_returns_all_rows() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "alpha", 1, 5, "h-a").unwrap();
db.insert_file("Notes/B.md", "beta", 2, 4, "h-b").unwrap();

let metas = db.list_meta().unwrap();
assert_eq!(metas.len(), 2);
assert_eq!(metas[0].path, "Notes/A.md");
assert_eq!(metas[1].path, "Notes/B.md");
}
}
75 changes: 75 additions & 0 deletions crates/trachyte-ipc/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,42 @@ fn db_to_ipc(e: trachyte_db::DbError) -> IpcError {
}
}

/// Insert / update a file in the vault index (`.trachyte/index.db`).
#[tauri::command]
#[specta::specta]
pub fn index_upsert_file(
vault_path: String,
rel_path: String,
content: String,
mtime: i64,
) -> Result<i64, IpcError> {
let db = open_index(&vault_path)?;
db.upsert_file(
&rel_path,
&content,
mtime,
content.len() as i64,
&hash_of(&content),
)
.map_err(db_to_ipc)
}

/// Remove a file from the vault index.
#[tauri::command]
#[specta::specta]
pub fn index_delete_file(vault_path: String, rel_path: String) -> Result<(), IpcError> {
let db = open_index(&vault_path)?;
db.delete_file(&rel_path).map_err(db_to_ipc)
}

/// List all indexed files with their metadata.
#[tauri::command]
#[specta::specta]
pub fn index_list_files(vault_path: String) -> Result<Vec<trachyte_db::FileMeta>, IpcError> {
let db = open_index(&vault_path)?;
db.list_meta().map_err(db_to_ipc)
}

/// Tests
#[cfg(test)]
mod tests {
Expand Down Expand Up @@ -279,4 +315,43 @@ mod tests {
let err = index_search(path, "\"".into()).unwrap_err();
assert!(matches!(err, IpcError::InvalidQuery(_)));
}

#[test]
fn index_upsert_updates_same_path() {
let dir = tempfile::tempdir().unwrap();
let path = create_vault(dir.path());

let id1 = index_upsert_file(path.clone(), "Notes/A.md".into(), "first".into(), 0).unwrap();
let id2 = index_upsert_file(path.clone(), "Notes/A.md".into(), "second".into(), 0).unwrap();
assert_eq!(id1, id2);

let hits = index_search(path.clone(), "second".into()).unwrap();
assert_eq!(hits, vec!["Notes/A.md".to_string()]);
assert!(index_search(path, "first".into()).unwrap().is_empty());
}

#[test]
fn index_delete_removes_row() {
let dir = tempfile::tempdir().unwrap();
let path = create_vault(dir.path());
index_upsert_file(path.clone(), "Notes/A.md".into(), "hello world".into(), 0).unwrap();

index_delete_file(path.clone(), "Notes/A.md".into()).unwrap();

assert!(index_search(path, "world".into()).unwrap().is_empty());
}

#[test]
fn index_list_files_returns_meta() {
let dir = tempfile::tempdir().unwrap();
let path = create_vault(dir.path());
index_upsert_file(path.clone(), "Notes/A.md".into(), "alpha".into(), 1).unwrap();
index_upsert_file(path.clone(), "Notes/B.md".into(), "beta".into(), 2).unwrap();

let metas = index_list_files(path).unwrap();
assert_eq!(metas.len(), 2);
assert_eq!(metas[0].path, "Notes/A.md");
assert_eq!(metas[0].mtime, 1);
assert_eq!(metas[0].size, 5);
}
}
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default tseslint.config(
rules: {
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./settings/index.js";
export * from "./events/types.js";
export * from "./path.js";
export * from "./doctor/index.js";
export * from "./index/index.js";
Loading