@@ -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 ) ]
5161pub 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
15591665fn 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