1use async_trait::async_trait;
33use std::sync::Arc;
34use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
35
36use crate::audit_chain::{AuditKey, ChainRow, EventRow, 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 fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError>;
169
170 async fn verify_audit_chain(
171 &self,
172 key: &AuditKey,
173 ) -> Result<VerifyOutcome, TransactionStoreError>;
174
175 async fn verify_event_chain(
182 &self,
183 key: &AuditKey,
184 ) -> Result<VerifyOutcome, TransactionStoreError> {
185 let rows = self.fetch_event_rows().await?;
186 Ok(crate::audit_chain::verify_event_chain(key, &rows))
187 }
188
189 async fn verify_event_binding(
192 &self,
193 ) -> Result<crate::audit_chain::BindingOutcome, TransactionStoreError> {
194 let tx_rows = self.fetch_chain_rows().await?;
195 let event_rows = self.fetch_event_rows().await?;
196 Ok(crate::audit_chain::verify_event_binding(
197 &tx_rows,
198 &event_rows,
199 ))
200 }
201}
202
203#[derive(Debug, Clone)]
208pub struct SqliteStore {
209 inner: Arc<TransactionStore>,
210}
211
212impl SqliteStore {
213 pub fn new(inner: TransactionStore) -> Self {
214 Self {
215 inner: Arc::new(inner),
216 }
217 }
218
219 pub fn inner(&self) -> &TransactionStore {
223 &self.inner
224 }
225}
226
227async fn blocking<F, R>(f: F) -> R
234where
235 F: FnOnce() -> R + Send + 'static,
236 R: Send + 'static,
237{
238 tokio::task::spawn_blocking(f)
239 .await
240 .expect("audit-store blocking task panicked")
241}
242
243#[async_trait]
244impl AuditStore for SqliteStore {
245 async fn record(
246 &self,
247 transaction: NewTransaction,
248 ) -> Result<TransactionRecord, TransactionStoreError> {
249 let inner = Arc::clone(&self.inner);
250 blocking(move || inner.record(transaction)).await
251 }
252
253 async fn record_previewed(
254 &self,
255 transaction: NewTransaction,
256 preview: PreviewEnvelope,
257 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
258 let inner = Arc::clone(&self.inner);
259 blocking(move || inner.record_previewed(transaction, preview)).await
260 }
261
262 async fn get(
263 &self,
264 transaction_id: &str,
265 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
266 let inner = Arc::clone(&self.inner);
267 let id = transaction_id.to_string();
268 blocking(move || inner.get(&id)).await
269 }
270
271 async fn get_preview(
272 &self,
273 transaction_id: &str,
274 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
275 let inner = Arc::clone(&self.inner);
276 let id = transaction_id.to_string();
277 blocking(move || inner.get_preview(&id)).await
278 }
279
280 async fn update_status(
281 &self,
282 transaction_id: &str,
283 new_status: JobState,
284 ) -> Result<(), TransactionStoreError> {
285 let inner = Arc::clone(&self.inner);
286 let id = transaction_id.to_string();
287 blocking(move || inner.update_status(&id, new_status)).await
288 }
289
290 async fn approve_transaction(
291 &self,
292 transaction_id: &str,
293 ) -> Result<Option<String>, TransactionStoreError> {
294 let inner = Arc::clone(&self.inner);
295 let id = transaction_id.to_string();
296 blocking(move || inner.approve_transaction(&id)).await
297 }
298
299 async fn revoke_unconsumed_approval(
300 &self,
301 transaction_id: &str,
302 ) -> Result<bool, TransactionStoreError> {
303 let inner = Arc::clone(&self.inner);
304 let id = transaction_id.to_string();
305 blocking(move || inner.revoke_unconsumed_approval(&id)).await
306 }
307
308 async fn claim_approved_for_execution(
309 &self,
310 transaction_id: &str,
311 receipt_digest: &str,
312 ) -> Result<bool, TransactionStoreError> {
313 let inner = Arc::clone(&self.inner);
314 let id = transaction_id.to_string();
315 let digest = receipt_digest.to_string();
316 blocking(move || inner.claim_approved_for_execution(&id, &digest)).await
317 }
318
319 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
320 let inner = Arc::clone(&self.inner);
321 blocking(move || inner.cleanup_stale_queued()).await
322 }
323
324 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
325 let inner = Arc::clone(&self.inner);
326 let id = transaction_id.to_string();
327 blocking(move || inner.cancel_queued(&id)).await
328 }
329
330 async fn list_transactions(
331 &self,
332 limit: u32,
333 status_filter: Option<&str>,
334 action_filter: Option<&str>,
335 since_hours: Option<u32>,
336 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
337 let inner = Arc::clone(&self.inner);
338 let status = status_filter.map(str::to_string);
339 let action = action_filter.map(str::to_string);
340 blocking(move || {
341 inner.list_transactions(limit, status.as_deref(), action.as_deref(), since_hours)
342 })
343 .await
344 }
345
346 async fn list_history(
347 &self,
348 limit: u32,
349 status_filter: Option<&str>,
350 action_filter: Option<&str>,
351 since_hours: Option<u32>,
352 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
353 let inner = Arc::clone(&self.inner);
354 let status = status_filter.map(str::to_string);
355 let action = action_filter.map(str::to_string);
356 blocking(move || {
357 inner.list_history(limit, status.as_deref(), action.as_deref(), since_hours)
358 })
359 .await
360 }
361
362 async fn fetch_chain_row(
363 &self,
364 transaction_id: &str,
365 ) -> Result<Option<ChainRow>, TransactionStoreError> {
366 let inner = Arc::clone(&self.inner);
367 let id = transaction_id.to_string();
368 blocking(move || inner.fetch_chain_row(&id)).await
369 }
370
371 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
372 let inner = Arc::clone(&self.inner);
373 blocking(move || inner.fetch_chain_rows()).await
374 }
375
376 async fn fetch_event_rows(&self) -> Result<Vec<EventRow>, TransactionStoreError> {
377 let inner = Arc::clone(&self.inner);
378 blocking(move || inner.fetch_event_rows()).await
379 }
380
381 async fn verify_audit_chain(
382 &self,
383 key: &AuditKey,
384 ) -> Result<VerifyOutcome, TransactionStoreError> {
385 let rows = self.fetch_chain_rows().await?;
390 Ok(crate::audit_chain::verify_chain(key, &rows))
391 }
392}
393
394pub use postgres::PostgresStore;
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::audit_chain::AuditKey;
401 use crate::transactions::NewTransaction;
402 use sysknife_types::{CallerRole, RiskLevel};
403 use tempfile::tempdir;
404
405 fn sqlite_store(path: std::path::PathBuf) -> SqliteStore {
406 let key = Arc::new(AuditKey::from_bytes(vec![0x42; 32]));
407 SqliteStore::new(crate::transactions::TransactionStore::open_with_key(path, key).unwrap())
408 }
409
410 fn new_tx(request_id: &str) -> NewTransaction {
411 NewTransaction {
412 request_id: request_id.to_string(),
413 request_hash: format!("hash-{request_id}"),
414 action_name: "UpdateSystem".to_string(),
415 risk_level: RiskLevel::High,
416 summary: "Upgrade the system".to_string(),
417 warnings: vec![],
418 caller_role: CallerRole::Dev,
419 }
420 }
421
422 #[tokio::test]
423 async fn reconcile_interrupted_running_fails_only_running_rows() {
424 let dir = tempdir().unwrap();
425 let store = sqlite_store(dir.path().join("tx.db"));
426
427 let running = store.record(new_tx("running")).await.unwrap();
430 store
431 .update_status(&running.transaction_id, JobState::Running)
432 .await
433 .unwrap();
434
435 let queued = store.record(new_tx("queued")).await.unwrap();
436
437 let succeeded = store.record(new_tx("succeeded")).await.unwrap();
438 store
439 .update_status(&succeeded.transaction_id, JobState::Running)
440 .await
441 .unwrap();
442 store
443 .update_status(&succeeded.transaction_id, JobState::Succeeded)
444 .await
445 .unwrap();
446
447 let reconciled = store.reconcile_interrupted_running().await.unwrap();
448 assert_eq!(reconciled, 1, "only the one Running row is reconciled");
449
450 assert_eq!(
451 store
452 .get(&running.transaction_id)
453 .await
454 .unwrap()
455 .unwrap()
456 .status,
457 JobState::Failed,
458 "orphaned Running row must become Failed"
459 );
460 assert_eq!(
461 store
462 .get(&queued.transaction_id)
463 .await
464 .unwrap()
465 .unwrap()
466 .status,
467 JobState::Queued,
468 "Queued row is untouched"
469 );
470 assert_eq!(
471 store
472 .get(&succeeded.transaction_id)
473 .await
474 .unwrap()
475 .unwrap()
476 .status,
477 JobState::Succeeded,
478 "terminal Succeeded row is untouched"
479 );
480
481 assert_eq!(store.reconcile_interrupted_running().await.unwrap(), 0);
483 }
484}