crtx-store 0.1.0

SQLite persistence: migrations, repositories, transactions.
Documentation
//! Audit repository operations.

use chrono::{DateTime, Utc};
use cortex_core::AuditRecordId;
use rusqlite::{params, Row};
use serde_json::Value;

use crate::{Pool, StoreResult};

/// Insertable audit row matching the current store schema.
#[derive(Debug, Clone, PartialEq)]
pub struct AuditEntry {
    /// Stable audit row identifier.
    pub id: AuditRecordId,
    /// Operation identifier.
    pub operation: String,
    /// Target reference.
    pub target_ref: String,
    /// Optional pre-operation hash.
    pub before_hash: Option<String>,
    /// Post-operation hash.
    pub after_hash: String,
    /// Operator-facing reason.
    pub reason: String,
    /// Actor descriptor JSON.
    pub actor_json: Value,
    /// Source references JSON.
    pub source_refs_json: Value,
    /// Creation timestamp.
    pub created_at: DateTime<Utc>,
}

/// Repository for audit rows.
#[derive(Debug)]
pub struct AuditRepo<'a> {
    pool: &'a Pool,
}

impl<'a> AuditRepo<'a> {
    /// Creates an audit repository over an open SQLite connection.
    #[must_use]
    pub const fn new(pool: &'a Pool) -> Self {
        Self { pool }
    }

    /// Appends one audit row.
    pub fn append(&self, entry: &AuditEntry) -> StoreResult<()> {
        self.pool.execute(
            "INSERT INTO audit_records (
                id, operation, target_ref, before_hash, after_hash, reason,
                actor_json, source_refs_json, created_at
             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
            params![
                entry.id.to_string(),
                entry.operation,
                entry.target_ref,
                entry.before_hash,
                entry.after_hash,
                entry.reason,
                serde_json::to_string(&entry.actor_json)?,
                serde_json::to_string(&entry.source_refs_json)?,
                entry.created_at.to_rfc3339(),
            ],
        )?;

        Ok(())
    }

    /// Lists audit rows for a target reference in insertion order.
    pub fn list_by_target_ref(&self, target_ref: &str) -> StoreResult<Vec<AuditEntry>> {
        let mut stmt = self.pool.prepare(
            "SELECT id, operation, target_ref, before_hash, after_hash, reason,
                    actor_json, source_refs_json, created_at
             FROM audit_records
             WHERE target_ref = ?1
             ORDER BY created_at, id;",
        )?;
        let rows = stmt.query_map(params![target_ref], audit_row)?;

        let mut entries = Vec::new();
        for row in rows {
            entries.push(row?.try_into()?);
        }
        Ok(entries)
    }
}

#[derive(Debug)]
struct AuditEntryRow {
    id: String,
    operation: String,
    target_ref: String,
    before_hash: Option<String>,
    after_hash: String,
    reason: String,
    actor_json: String,
    source_refs_json: String,
    created_at: String,
}

fn audit_row(row: &Row<'_>) -> rusqlite::Result<AuditEntryRow> {
    Ok(AuditEntryRow {
        id: row.get(0)?,
        operation: row.get(1)?,
        target_ref: row.get(2)?,
        before_hash: row.get(3)?,
        after_hash: row.get(4)?,
        reason: row.get(5)?,
        actor_json: row.get(6)?,
        source_refs_json: row.get(7)?,
        created_at: row.get(8)?,
    })
}

impl TryFrom<AuditEntryRow> for AuditEntry {
    type Error = crate::StoreError;

    fn try_from(row: AuditEntryRow) -> StoreResult<Self> {
        Ok(Self {
            id: row.id.parse()?,
            operation: row.operation,
            target_ref: row.target_ref,
            before_hash: row.before_hash,
            after_hash: row.after_hash,
            reason: row.reason,
            actor_json: serde_json::from_str(&row.actor_json)?,
            source_refs_json: serde_json::from_str(&row.source_refs_json)?,
            created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
        })
    }
}