Skip to content

Commit c058079

Browse files
committed
feat(store): add cli_events table and delete_file_hard
1 parent c31b4f0 commit c058079

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

src/store.rs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ pub struct EdgeStats {
4646
pub isolated_file_count: usize,
4747
}
4848

49+
/// A record representing a CLI event (for observability/analytics).
50+
#[derive(Debug, Clone)]
51+
pub struct CliEvent {
52+
pub id: i64,
53+
pub timestamp: String,
54+
pub operation: String,
55+
pub outcome: String,
56+
pub detail: Option<String>,
57+
}
58+
4959
/// A record of a placement correction (user moved a note from suggested folder).
5060
#[derive(Debug, Clone)]
5161
pub struct PlacementCorrection {
@@ -281,6 +291,18 @@ impl Store {
281291
);",
282292
)?;
283293

294+
// CLI events table (observability/analytics)
295+
self.conn.execute_batch(
296+
"CREATE TABLE IF NOT EXISTS cli_events (
297+
id INTEGER PRIMARY KEY,
298+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
299+
operation TEXT NOT NULL,
300+
outcome TEXT NOT NULL,
301+
detail TEXT
302+
);
303+
CREATE INDEX IF NOT EXISTS idx_cli_events_ts ON cli_events(timestamp);",
304+
)?;
305+
284306
Ok(())
285307
}
286308

@@ -1554,6 +1576,90 @@ impl Store {
15541576
pub fn register_tag(&self, name: &str, created_by: &str) -> Result<()> {
15551577
crate::tags::register_tag(&self.conn, name, created_by)
15561578
}
1579+
1580+
// ── CLI Events ──────────────────────────────────────────────
1581+
1582+
/// Log a CLI event for observability/analytics.
1583+
pub fn log_cli_event(
1584+
&self,
1585+
operation: &str,
1586+
outcome: &str,
1587+
detail: Option<&str>,
1588+
) -> Result<()> {
1589+
self.conn.execute(
1590+
"INSERT INTO cli_events (timestamp, operation, outcome, detail)
1591+
VALUES (datetime('now'), ?1, ?2, ?3)",
1592+
params![operation, outcome, detail],
1593+
)?;
1594+
Ok(())
1595+
}
1596+
1597+
/// Get CLI events since a given ISO-8601 date string (e.g., "2020-01-01").
1598+
pub fn get_cli_events_since(&self, since: &str) -> Result<Vec<CliEvent>> {
1599+
let mut stmt = self.conn.prepare(
1600+
"SELECT id, timestamp, operation, outcome, detail
1601+
FROM cli_events WHERE timestamp >= ?1 ORDER BY timestamp DESC",
1602+
)?;
1603+
let rows = stmt.query_map(params![since], |row| {
1604+
Ok(CliEvent {
1605+
id: row.get(0)?,
1606+
timestamp: row.get(1)?,
1607+
operation: row.get(2)?,
1608+
outcome: row.get(3)?,
1609+
detail: row.get(4)?,
1610+
})
1611+
})?;
1612+
let mut results = Vec::new();
1613+
for row in rows {
1614+
results.push(row?);
1615+
}
1616+
Ok(results)
1617+
}
1618+
1619+
/// Prune CLI events older than the given number of days.
1620+
pub fn prune_cli_events(&self, days: u32) -> Result<usize> {
1621+
let deleted = self.conn.execute(
1622+
"DELETE FROM cli_events WHERE julianday('now') - julianday(timestamp) > ?1",
1623+
params![days],
1624+
)?;
1625+
Ok(deleted)
1626+
}
1627+
1628+
// ── Hard delete ──────────────────────────────────────────────
1629+
1630+
/// Completely remove a file and all associated data from the store.
1631+
///
1632+
/// Deletion order:
1633+
/// 1. Collect chunk vector_ids for the file
1634+
/// 2. Delete from `chunks_vec` (virtual table, no CASCADE)
1635+
/// 3. Delete from `chunks_fts` (virtual table, no CASCADE)
1636+
/// 4. Delete from `edges` where from_file or to_file matches
1637+
/// 5. Delete from `files` (CASCADE handles chunks table)
1638+
pub fn delete_file_hard(&self, path: &str) -> Result<()> {
1639+
let file = self
1640+
.get_file(path)?
1641+
.ok_or_else(|| anyhow::anyhow!("file not found: {}", path))?;
1642+
let file_id = file.id;
1643+
1644+
// 1. Collect chunk vector_ids
1645+
let vector_ids = self.get_vector_ids_for_file(file_id)?;
1646+
1647+
// 2. Delete from chunks_vec (virtual table — no CASCADE)
1648+
for vid in &vector_ids {
1649+
self.delete_vec(*vid)?;
1650+
}
1651+
1652+
// 3. Delete from chunks_fts (virtual table — no CASCADE)
1653+
self.delete_fts_chunks_for_file(file_id)?;
1654+
1655+
// 4. Delete from edges (both directions)
1656+
self.delete_edges_for_file(file_id)?;
1657+
1658+
// 5. Delete from files (CASCADE handles chunks table)
1659+
self.delete_file(file_id)?;
1660+
1661+
Ok(())
1662+
}
15571663
}
15581664

15591665
fn parse_tags(json: &str) -> Vec<String> {
@@ -2782,4 +2888,106 @@ mod tests {
27822888
let result = store.resolve_file("#abc123").unwrap();
27832889
assert!(result.is_some());
27842890
}
2891+
2892+
// ── CLI events tests ────────────────────────────────────────
2893+
2894+
#[test]
2895+
fn test_cli_events_insert_and_query() {
2896+
let store = Store::open_memory().unwrap();
2897+
store.log_cli_event("edit", "success", None).unwrap();
2898+
store
2899+
.log_cli_event("edit", "fallback", Some("timeout"))
2900+
.unwrap();
2901+
let events = store.get_cli_events_since("2020-01-01").unwrap();
2902+
assert_eq!(events.len(), 2);
2903+
assert_eq!(events[0].operation, "edit");
2904+
assert_eq!(events[1].operation, "edit");
2905+
// Most recent first
2906+
assert_eq!(events[0].outcome, "fallback");
2907+
assert_eq!(events[0].detail.as_deref(), Some("timeout"));
2908+
assert_eq!(events[1].outcome, "success");
2909+
assert!(events[1].detail.is_none());
2910+
}
2911+
2912+
#[test]
2913+
fn test_cli_events_prune() {
2914+
let store = Store::open_memory().unwrap();
2915+
store.log_cli_event("search", "success", None).unwrap();
2916+
// Events inserted just now should NOT be pruned with days=0 (julianday diff ~0)
2917+
let pruned = store.prune_cli_events(1).unwrap();
2918+
assert_eq!(pruned, 0);
2919+
let events = store.get_cli_events_since("2020-01-01").unwrap();
2920+
assert_eq!(events.len(), 1);
2921+
}
2922+
2923+
#[test]
2924+
fn test_cli_events_table_exists() {
2925+
let store = Store::open_memory().unwrap();
2926+
let tables: Vec<String> = {
2927+
let mut stmt = store
2928+
.conn
2929+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cli_events'")
2930+
.unwrap();
2931+
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
2932+
rows.filter_map(|r| r.ok()).collect()
2933+
};
2934+
assert!(tables.contains(&"cli_events".to_string()));
2935+
}
2936+
2937+
// ── delete_file_hard tests ──────────────────────────────────
2938+
2939+
#[test]
2940+
fn test_delete_file_hard() {
2941+
let store = Store::open_memory().unwrap();
2942+
let tags = vec!["tag".to_string()];
2943+
let file_id = store
2944+
.insert_file("delete-me.md", "hash", 100, &tags, "del123", None)
2945+
.unwrap();
2946+
2947+
// Insert a chunk + FTS entry + vec entry for the file
2948+
let vid = store.next_vector_id().unwrap();
2949+
store
2950+
.insert_chunk(file_id, "## Heading", "chunk text", vid, 10)
2951+
.unwrap();
2952+
store.insert_fts_chunk(file_id, 0, "chunk text").unwrap();
2953+
2954+
// Insert an embedding vector into chunks_vec
2955+
let embedding = vec![0.1_f32; 256];
2956+
store.insert_vec(vid, &embedding).unwrap();
2957+
2958+
// Insert an edge from this file to itself (just to test edge cleanup)
2959+
let file_id2 = store
2960+
.insert_file("other.md", "hash2", 100, &[], "oth123", None)
2961+
.unwrap();
2962+
store.insert_edge(file_id, file_id2, "wikilink").unwrap();
2963+
store.insert_edge(file_id2, file_id, "wikilink").unwrap();
2964+
2965+
// Verify data exists
2966+
assert!(store.get_file("delete-me.md").unwrap().is_some());
2967+
assert_eq!(store.get_chunks_by_file(file_id).unwrap().len(), 1);
2968+
2969+
// Hard delete
2970+
store.delete_file_hard("delete-me.md").unwrap();
2971+
2972+
// File is gone
2973+
assert!(store.get_file("delete-me.md").unwrap().is_none());
2974+
// Chunks are gone (CASCADE)
2975+
assert_eq!(store.get_chunks_by_file(file_id).unwrap().len(), 0);
2976+
// FTS entries are gone
2977+
let fts_results = store.fts_search("chunk text", 10).unwrap();
2978+
assert!(fts_results.is_empty());
2979+
// Edges are gone
2980+
assert_eq!(store.edge_count_for_file(file_id).unwrap(), 0);
2981+
// Only the edge from file_id2 to file_id was deleted, not file_id2's other edges
2982+
// (file_id2 has no remaining edges since both directions involved file_id)
2983+
assert_eq!(store.edge_count_for_file(file_id2).unwrap(), 0);
2984+
}
2985+
2986+
#[test]
2987+
fn test_delete_file_hard_not_found() {
2988+
let store = Store::open_memory().unwrap();
2989+
let result = store.delete_file_hard("nonexistent.md");
2990+
assert!(result.is_err());
2991+
assert!(result.unwrap_err().to_string().contains("file not found"));
2992+
}
27852993
}

0 commit comments

Comments
 (0)