Skip to main content

vfa_tui/persistence/
writer.rs

1//! Single-writer task for all SQLite mutations.
2//!
3//! Implements Req 19.8: all write operations are funnelled through one tokio
4//! task that owns the write `Connection`, preventing contention while allowing
5//! multiple concurrent read connections for the UI thread and background tasks.
6
7use tokio::sync::{mpsc, oneshot};
8
9use crate::error::TuiError;
10use crate::models::audit::AuditEntry;
11use crate::persistence::index::IndexManager;
12
13// ---------------------------------------------------------------------------
14// DbCommand — the message type sent to the writer task
15// ---------------------------------------------------------------------------
16
17/// Commands dispatched to the single-writer SQLite task.
18#[derive(Debug)]
19pub enum DbCommand {
20    /// Upsert a workspace-level scan summary row.
21    RecordScan {
22        workspace_path: String,
23        workspace_name: String,
24        last_scan_ts: String,
25        scan_duration_ms: i64,
26        asset_count: i64,
27        fs_mtime_at_scan: i64,
28    },
29    /// Record the result of a gate execution.
30    RecordGateResult {
31        gate_name: String,
32        status: String,
33        exit_code: Option<i32>,
34        duration_ms: i64,
35        timestamp: String,
36        catalog_hash: String,
37        output_excerpt: Option<String>,
38    },
39    /// Append a drift-detection record to `drift_history` (Req 10.x).
40    RecordDrift {
41        workspace_path: String,
42        asset_id: String,
43        drift_type: String,
44        first_detected: String,
45        expected_hash: String,
46        actual_hash: String,
47    },
48    /// Upsert a per-workspace coverage score into `coverage_cache` (Req 3.6).
49    RecordCoverageScore {
50        workspace_path: String,
51        workspace_name: String,
52        coverage_score: f64,
53        computed_at: String,
54    },
55    /// Append a pre-computed audit entry (hash chain already linked).
56    AppendAudit(AuditEntry),
57    /// Upsert a content-hash cache entry.
58    UpdateContentHash { path: String, hash: String },
59    /// Flush all pending work; reply on `ack` when done.
60    Flush(oneshot::Sender<()>),
61    /// Graceful shutdown: drain the queue then exit the task.
62    Shutdown,
63}
64
65// ---------------------------------------------------------------------------
66// WriterHandle — returned to callers
67// ---------------------------------------------------------------------------
68
69/// Sender half of the writer task channel.
70pub type WriterHandle = mpsc::Sender<DbCommand>;
71
72// ---------------------------------------------------------------------------
73// spawn_writer
74// ---------------------------------------------------------------------------
75
76/// Spawn the single-writer background task and return its channel sender.
77///
78/// The task owns a dedicated write `Connection` opened from `db_path`.
79/// If `db_path` is `None` an in-memory `IndexManager` is used — useful for
80/// tests (Req 19.5).
81///
82/// # Errors
83/// Returns `Err` if the `IndexManager` cannot be opened.
84pub fn spawn_writer(
85    db_path: Option<&str>,
86    channel_capacity: usize,
87) -> Result<WriterHandle, TuiError> {
88    let mgr = match db_path {
89        Some(p) => IndexManager::open(p)?,
90        None => IndexManager::open_in_memory()?,
91    };
92
93    let (tx, mut rx) = mpsc::channel::<DbCommand>(channel_capacity);
94
95    // `IndexManager` / `Connection` are `!Send`, so we must run the writer
96    // loop on a dedicated blocking thread (not a tokio async task).
97    // `spawn_blocking` accepts `!Send` closures; `blocking_recv()` is the
98    // sync equivalent of `recv().await`.
99    tokio::task::spawn_blocking(move || {
100        // The IndexManager is moved into the task; only this task touches the
101        // write connection from here on.
102        let conn = mgr.write_conn();
103
104        while let Some(cmd) = rx.blocking_recv() {
105            match cmd {
106                DbCommand::RecordScan {
107                    workspace_path,
108                    workspace_name,
109                    last_scan_ts,
110                    scan_duration_ms,
111                    asset_count,
112                    fs_mtime_at_scan,
113                } => {
114                    let _ = conn.execute(
115                        "INSERT OR REPLACE INTO workspace_scans \
116                         (workspace_path, workspace_name, last_scan_ts, \
117                          scan_duration_ms, asset_count, fs_mtime_at_scan) \
118                         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
119                        rusqlite::params![
120                            workspace_path,
121                            workspace_name,
122                            last_scan_ts,
123                            scan_duration_ms,
124                            asset_count,
125                            fs_mtime_at_scan,
126                        ],
127                    );
128                }
129
130                DbCommand::RecordGateResult {
131                    gate_name,
132                    status,
133                    exit_code,
134                    duration_ms,
135                    timestamp,
136                    catalog_hash,
137                    output_excerpt,
138                } => {
139                    let _ = conn.execute(
140                        "INSERT INTO gate_history \
141                         (gate_name, status, exit_code, duration_ms, timestamp, \
142                          catalog_hash, output_excerpt) \
143                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
144                        rusqlite::params![
145                            gate_name,
146                            status,
147                            exit_code,
148                            duration_ms,
149                            timestamp,
150                            catalog_hash,
151                            output_excerpt,
152                        ],
153                    );
154                }
155
156                DbCommand::RecordDrift {
157                    workspace_path,
158                    asset_id,
159                    drift_type,
160                    first_detected,
161                    expected_hash,
162                    actual_hash,
163                } => {
164                    let _ = conn.execute(
165                        "INSERT INTO drift_history \
166                         (workspace_path, asset_id, drift_type, first_detected, \
167                          resolved_at, expected_hash, actual_hash) \
168                         VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)",
169                        rusqlite::params![
170                            workspace_path,
171                            asset_id,
172                            drift_type,
173                            first_detected,
174                            expected_hash,
175                            actual_hash,
176                        ],
177                    );
178                }
179
180                DbCommand::RecordCoverageScore {
181                    workspace_path,
182                    workspace_name,
183                    coverage_score,
184                    computed_at,
185                } => {
186                    let _ = conn.execute(
187                        "INSERT OR REPLACE INTO coverage_cache \
188                         (workspace_path, workspace_name, coverage_score, computed_at) \
189                         VALUES (?1, ?2, ?3, ?4)",
190                        rusqlite::params![
191                            workspace_path,
192                            workspace_name,
193                            coverage_score,
194                            computed_at,
195                        ],
196                    );
197                }
198
199                DbCommand::AppendAudit(entry) => {
200                    let event_type_str =
201                        serde_json::to_string(&entry.event_type).unwrap_or_default();
202                    let details_str =
203                        serde_json::to_string(&entry.details).unwrap_or_else(|_| "{}".to_string());
204                    let _ = conn.execute(
205                        "INSERT INTO audit_log \
206                         (timestamp, event_type, subject, details, operator, \
207                          entry_hash, prev_hash) \
208                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
209                        rusqlite::params![
210                            entry.timestamp,
211                            event_type_str.trim_matches('"'),
212                            entry.subject,
213                            details_str,
214                            entry.operator,
215                            entry.entry_hash,
216                            entry.prev_hash,
217                        ],
218                    );
219                }
220
221                DbCommand::UpdateContentHash { path, hash } => {
222                    let _ = conn.execute(
223                        "INSERT OR REPLACE INTO content_hashes (path, hash, last_checked) \
224                         VALUES (?1, ?2, datetime('now'))",
225                        rusqlite::params![path, hash],
226                    );
227                }
228
229                DbCommand::Flush(ack) => {
230                    let _ = ack.send(());
231                }
232
233                DbCommand::Shutdown => {
234                    break;
235                }
236            }
237        }
238    });
239
240    Ok(tx)
241}
242
243// ---------------------------------------------------------------------------
244// Tests
245// ---------------------------------------------------------------------------
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::models::audit::AuditEventType;
251    use crate::persistence::index::IndexManager;
252
253    fn make_audit_entry(id: i64, entry_hash: &str, prev_hash: &str) -> AuditEntry {
254        AuditEntry {
255            id,
256            timestamp: "2025-01-01T00:00:00.000Z".to_string(),
257            event_type: AuditEventType::PolicyEvaluation,
258            subject: "ws".to_string(),
259            details: serde_json::json!({}),
260            operator: "system".to_string(),
261            entry_hash: entry_hash.to_string(),
262            prev_hash: prev_hash.to_string(),
263        }
264    }
265
266    #[tokio::test]
267    async fn spawn_writer_inmemory_flush_roundtrip() {
268        let tx = spawn_writer(None, 32).expect("spawn_writer");
269
270        // Send a scan record.
271        tx.send(DbCommand::RecordScan {
272            workspace_path: "/ws/test".to_string(),
273            workspace_name: "test".to_string(),
274            last_scan_ts: "2025-01-01T00:00:00Z".to_string(),
275            scan_duration_ms: 100,
276            asset_count: 5,
277            fs_mtime_at_scan: 1000,
278        })
279        .await
280        .unwrap();
281
282        // Flush and wait for ack.
283        let (ack_tx, ack_rx) = oneshot::channel();
284        tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
285        ack_rx.await.expect("flush ack");
286    }
287
288    #[tokio::test]
289    async fn spawn_writer_sends_audit_entry() {
290        let tx = spawn_writer(None, 32).expect("spawn_writer");
291
292        let entry = make_audit_entry(0, "ehash", "");
293        tx.send(DbCommand::AppendAudit(entry)).await.unwrap();
294
295        let (ack_tx, ack_rx) = oneshot::channel();
296        tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
297        ack_rx.await.expect("flush ack");
298    }
299
300    #[tokio::test]
301    async fn spawn_writer_shutdown_drops_channel() {
302        let tx = spawn_writer(None, 32).expect("spawn_writer");
303        tx.send(DbCommand::Shutdown).await.unwrap();
304        // After shutdown the task exits; subsequent sends fail.
305        // Allow a brief yield for the task to process Shutdown.
306        tokio::task::yield_now().await;
307    }
308
309    /// Verify that the writer task properly handles UpdateContentHash.
310    #[tokio::test]
311    async fn spawn_writer_update_content_hash() {
312        let tx = spawn_writer(None, 32).expect("spawn_writer");
313
314        tx.send(DbCommand::UpdateContentHash {
315            path: "/catalog/agents.json".to_string(),
316            hash: "abc123".to_string(),
317        })
318        .await
319        .unwrap();
320
321        let (ack_tx, ack_rx) = oneshot::channel();
322        tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
323        ack_rx.await.expect("flush ack");
324    }
325
326    /// Verify record gate result is accepted.
327    #[tokio::test]
328    async fn spawn_writer_record_gate_result() {
329        let tx = spawn_writer(None, 32).expect("spawn_writer");
330
331        tx.send(DbCommand::RecordGateResult {
332            gate_name: "lint".to_string(),
333            status: "pass".to_string(),
334            exit_code: Some(0),
335            duration_ms: 1200,
336            timestamp: "2025-01-01T00:00:00Z".to_string(),
337            catalog_hash: "chash".to_string(),
338            output_excerpt: None,
339        })
340        .await
341        .unwrap();
342
343        let (ack_tx, ack_rx) = oneshot::channel();
344        tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
345        ack_rx.await.expect("flush ack");
346    }
347
348    /// Confirm writer task works with a real file-based DB.
349    #[tokio::test]
350    async fn spawn_writer_file_based() {
351        let dir = tempfile::tempdir().expect("tempdir");
352        let path = dir.path().join("writer_test.db");
353        let path_str = path.to_str().expect("utf8 path").to_string();
354
355        // Pre-create and migrate.
356        let _mgr = IndexManager::open(&path_str).expect("open");
357
358        let tx = spawn_writer(Some(&path_str), 32).expect("spawn writer");
359
360        tx.send(DbCommand::RecordScan {
361            workspace_path: "/ws/file".to_string(),
362            workspace_name: "file-ws".to_string(),
363            last_scan_ts: "2025-01-01T00:00:00Z".to_string(),
364            scan_duration_ms: 200,
365            asset_count: 3,
366            fs_mtime_at_scan: 5000,
367        })
368        .await
369        .unwrap();
370
371        let (ack_tx, ack_rx) = oneshot::channel();
372        tx.send(DbCommand::Flush(ack_tx)).await.unwrap();
373        ack_rx.await.expect("flush ack");
374    }
375}