1use async_trait::async_trait;
33use std::sync::Arc;
34use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
35
36use crate::audit_chain::{AuditKey, ChainRow, VerifyOutcome};
37use crate::transactions::{
38 NewTransaction, RecordedPreviewedTransaction, TransactionStore, TransactionStoreError,
39};
40
41pub mod postgres;
42
43#[async_trait]
51pub trait AuditStore: Send + Sync + std::fmt::Debug {
52 async fn record(
53 &self,
54 transaction: NewTransaction,
55 ) -> Result<TransactionRecord, TransactionStoreError>;
56
57 async fn record_previewed(
58 &self,
59 transaction: NewTransaction,
60 preview: PreviewEnvelope,
61 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError>;
62
63 async fn get(
64 &self,
65 transaction_id: &str,
66 ) -> Result<Option<TransactionRecord>, TransactionStoreError>;
67
68 async fn get_preview(
69 &self,
70 transaction_id: &str,
71 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError>;
72
73 async fn update_status(
74 &self,
75 transaction_id: &str,
76 new_status: JobState,
77 ) -> Result<(), TransactionStoreError>;
78
79 async fn approve_transaction(
80 &self,
81 transaction_id: &str,
82 ) -> Result<Option<String>, TransactionStoreError>;
83
84 async fn revoke_unconsumed_approval(
85 &self,
86 transaction_id: &str,
87 ) -> Result<bool, TransactionStoreError>;
88
89 async fn claim_approved_for_execution(
90 &self,
91 transaction_id: &str,
92 receipt_digest: &str,
93 ) -> Result<bool, TransactionStoreError>;
94
95 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError>;
96
97 async fn reconcile_interrupted_running(&self) -> Result<u64, TransactionStoreError> {
113 let mut total = 0u64;
114 loop {
115 let running = self
116 .list_transactions(100, Some("running"), None, None)
117 .await?;
118 if running.is_empty() {
119 break;
120 }
121 let batch = running.len();
122 for tx in running {
123 self.update_status(&tx.transaction_id, JobState::Failed)
124 .await?;
125 }
126 total += batch as u64;
127 if batch < 100 {
129 break;
130 }
131 }
132 Ok(total)
133 }
134
135 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError>;
140
141 async fn list_transactions(
142 &self,
143 limit: u32,
144 status_filter: Option<&str>,
145 action_filter: Option<&str>,
146 since_hours: Option<u32>,
147 ) -> Result<Vec<TransactionRecord>, TransactionStoreError>;
148
149 async fn list_history(
152 &self,
153 limit: u32,
154 status_filter: Option<&str>,
155 action_filter: Option<&str>,
156 since_hours: Option<u32>,
157 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError>;
158
159 async fn fetch_chain_row(
160 &self,
161 transaction_id: &str,
162 ) -> Result<Option<ChainRow>, TransactionStoreError>;
163
164 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError>;
165
166 async fn verify_audit_chain(
167 &self,
168 key: &AuditKey,
169 ) -> Result<VerifyOutcome, TransactionStoreError>;
170}
171
172#[derive(Debug, Clone)]
177pub struct SqliteStore {
178 inner: Arc<TransactionStore>,
179}
180
181impl SqliteStore {
182 pub fn new(inner: TransactionStore) -> Self {
183 Self {
184 inner: Arc::new(inner),
185 }
186 }
187
188 pub fn inner(&self) -> &TransactionStore {
192 &self.inner
193 }
194}
195
196async fn blocking<F, R>(f: F) -> R
203where
204 F: FnOnce() -> R + Send + 'static,
205 R: Send + 'static,
206{
207 tokio::task::spawn_blocking(f)
208 .await
209 .expect("audit-store blocking task panicked")
210}
211
212#[async_trait]
213impl AuditStore for SqliteStore {
214 async fn record(
215 &self,
216 transaction: NewTransaction,
217 ) -> Result<TransactionRecord, TransactionStoreError> {
218 let inner = Arc::clone(&self.inner);
219 blocking(move || inner.record(transaction)).await
220 }
221
222 async fn record_previewed(
223 &self,
224 transaction: NewTransaction,
225 preview: PreviewEnvelope,
226 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
227 let inner = Arc::clone(&self.inner);
228 blocking(move || inner.record_previewed(transaction, preview)).await
229 }
230
231 async fn get(
232 &self,
233 transaction_id: &str,
234 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
235 let inner = Arc::clone(&self.inner);
236 let id = transaction_id.to_string();
237 blocking(move || inner.get(&id)).await
238 }
239
240 async fn get_preview(
241 &self,
242 transaction_id: &str,
243 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
244 let inner = Arc::clone(&self.inner);
245 let id = transaction_id.to_string();
246 blocking(move || inner.get_preview(&id)).await
247 }
248
249 async fn update_status(
250 &self,
251 transaction_id: &str,
252 new_status: JobState,
253 ) -> Result<(), TransactionStoreError> {
254 let inner = Arc::clone(&self.inner);
255 let id = transaction_id.to_string();
256 blocking(move || inner.update_status(&id, new_status)).await
257 }
258
259 async fn approve_transaction(
260 &self,
261 transaction_id: &str,
262 ) -> Result<Option<String>, TransactionStoreError> {
263 let inner = Arc::clone(&self.inner);
264 let id = transaction_id.to_string();
265 blocking(move || inner.approve_transaction(&id)).await
266 }
267
268 async fn revoke_unconsumed_approval(
269 &self,
270 transaction_id: &str,
271 ) -> Result<bool, TransactionStoreError> {
272 let inner = Arc::clone(&self.inner);
273 let id = transaction_id.to_string();
274 blocking(move || inner.revoke_unconsumed_approval(&id)).await
275 }
276
277 async fn claim_approved_for_execution(
278 &self,
279 transaction_id: &str,
280 receipt_digest: &str,
281 ) -> Result<bool, TransactionStoreError> {
282 let inner = Arc::clone(&self.inner);
283 let id = transaction_id.to_string();
284 let digest = receipt_digest.to_string();
285 blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
286 }
287
288 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
289 let inner = Arc::clone(&self.inner);
290 blocking(move || inner.cleanup_stale_queued()).await
291 }
292
293 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
294 let inner = Arc::clone(&self.inner);
295 let id = transaction_id.to_string();
296 blocking(move || inner.cancel_queued(&id)).await
297 }
298
299 async fn list_transactions(
300 &self,
301 limit: u32,
302 status_filter: Option<&str>,
303 action_filter: Option<&str>,
304 since_hours: Option<u32>,
305 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
306 let inner = Arc::clone(&self.inner);
307 let status = status_filter.map(str::to_string);
308 let action = action_filter.map(str::to_string);
309 blocking(move || {
310 inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
311 })
312 .await
313 }
314
315 async fn list_history(
316 &self,
317 limit: u32,
318 status_filter: Option<&str>,
319 action_filter: Option<&str>,
320 since_hours: Option<u32>,
321 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
322 let inner = Arc::clone(&self.inner);
323 let status = status_filter.map(str::to_string);
324 let action = action_filter.map(str::to_string);
325 blocking(move || {
326 inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
327 })
328 .await
329 }
330
331 async fn fetch_chain_row(
332 &self,
333 transaction_id: &str,
334 ) -> Result<Option<ChainRow>, TransactionStoreError> {
335 let inner = Arc::clone(&self.inner);
336 let id = transaction_id.to_string();
337 blocking(move || inner.fetch_chain_row(&id)).await
338 }
339
340 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
341 let inner = Arc::clone(&self.inner);
342 blocking(move || inner.fetch_chain_rows()).await
343 }
344
345 async fn verify_audit_chain(
346 &self,
347 key: &AuditKey,
348 ) -> Result<VerifyOutcome, TransactionStoreError> {
349 let rows = self.fetch_chain_rows().await?;
354 Ok(crate::audit_chain::verify_chain(key, &rows))
355 }
356}
357
358pub use postgres::PostgresStore;
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::audit_chain::AuditKey;
365 use crate::transactions::NewTransaction;
366 use sysknife_types::RiskLevel;
367 use tempfile::tempdir;
368
369 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
370 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
371 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
372 }
373
374 fn new_tx(request_id: &str) -> NewTransaction {
375 NewTransaction {
376 request_id: request_id.to_string(),
377 request_hash: format!("hash-{request_id}"),
378 action_name: "UpdateSystem".to_string(),
379 risk_level: RiskLevel::High,
380 approval_id: None,
381 summary: "Upgrade the system".to_string(),
382 warnings: vec![],
383 }
384 }
385
386 #[tokio::test]
387 async fn reconcile_interrupted_running_fails_only_running_rows() {
388 let dir = tempdir().unwrap();
389 let store = sqlite_store(dir.path().join("tx.db"));
390
391 let running = store.record(new_tx("running")).await.unwrap();
394 store
395 .update_status(&running.transaction_id, JobState::Running)
396 .await
397 .unwrap();
398
399 let queued = store.record(new_tx("queued")).await.unwrap();
400
401 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
402 store
403 .update_status(&succeeded.transaction_id, JobState::Running)
404 .await
405 .unwrap();
406 store
407 .update_status(&succeeded.transaction_id, JobState::Succeeded)
408 .await
409 .unwrap();
410
411 let reconciled = store.reconcile_interrupted_running().await.unwrap();
412 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
413
414 assert_eq!(
415 store
416 .get(&running.transaction_id)
417 .await
418 .unwrap()
419 .unwrap()
420 .status,
421 JobState::Failed,
422 "orphaned Running row must become Failed"
423 );
424 assert_eq!(
425 store
426 .get(&queued.transaction_id)
427 .await
428 .unwrap()
429 .unwrap()
430 .status,
431 JobState::Queued,
432 "Queued row is untouched"
433 );
434 assert_eq!(
435 store
436 .get(&succeeded.transaction_id)
437 .await
438 .unwrap()
439 .unwrap()
440 .status,
441 JobState::Succeeded,
442 "terminal Succeeded row is untouched"
443 );
444
445 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
447 }
448}