auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
//! A runnable end-to-end example: audit a `Post` model through its lifecycle, attribute changes to
//! a user, query the trail, and reconstruct a past revision.
//!
//! Run with: `cargo run --example blog`

use auditlog::{Actor, AuditId, AuditOptions, Auditable, SqlxBackend, ValueMap, as_user};
use serde_json::json;

#[derive(Clone)]
struct Post {
    id: i64,
    title: String,
    body: String,
    secret_token: String,
}

impl Auditable for Post {
    fn auditable_type() -> &'static str {
        "Post"
    }

    fn auditable_id(&self) -> AuditId {
        self.id.into()
    }

    fn audited_attributes(&self) -> ValueMap {
        let mut m = ValueMap::new();
        m.insert("id".into(), json!(self.id));
        m.insert("title".into(), json!(self.title));
        m.insert("body".into(), json!(self.body));
        m.insert("secret_token".into(), json!(self.secret_token));
        m
    }

    fn audit_options() -> AuditOptions {
        // Log *that* the token changed, but never its value.
        AuditOptions::builder().redacted(["secret_token"]).build()
    }
}

#[tokio::main]
async fn main() -> auditlog::Result<()> {
    // An in-memory SQLite store, created and migrated in two lines.
    let backend = SqlxBackend::connect_sqlite("sqlite::memory:").await?;
    backend.migrate().await?;

    // --- create, attributed to a user ---
    let mut post = Post {
        id: 1,
        title: "Hello".into(),
        body: "First draft.".into(),
        secret_token: "tok_abc".into(),
    };
    as_user(Actor::record("User", 42), async {
        post.audited_create(&backend).await
    })
    .await?;

    // --- update (diff old → new), with a comment ---
    let previous = post.clone();
    post.title = "Hello, world".into();
    post.secret_token = "tok_xyz".into();
    as_user(Actor::name("editor-bot"), async {
        post.audited_update_with_comment(&backend, &previous, "fixed the title")
            .await
    })
    .await?;

    // --- destroy ---
    post.audited_destroy(&backend).await?;

    // --- inspect the trail ---
    println!("Audit trail for Post #1:");
    for audit in Post::audits(&backend, 1).await? {
        let who = match audit.user() {
            Some(Actor::Record { user_type, user_id }) => format!("{user_type}#{user_id}"),
            Some(Actor::Name(name)) => name,
            None => "<system>".into(),
        };
        println!(
            "  v{} {:<7} by {:<10} changes={} {}",
            audit.version,
            audit.action.to_string(),
            who,
            serde_json::to_string(&audit.audited_changes).unwrap(),
            audit.comment.map(|c| format!("// {c}")).unwrap_or_default(),
        );
    }

    // Note: the secret_token is recorded as "[REDACTED]" everywhere.

    // --- reconstruct what the post looked like at version 1 ---
    let v1 = Post::revision(&backend, 1, 1).await?.expect("v1 exists");
    println!(
        "\nPost #1 at version 1: title = {}",
        v1.attributes.get("title").unwrap()
    );

    // --- the destroyed record can be reconstructed as a new (unsaved) row ---
    let revs = Post::revisions(&backend, 1).await?;
    let last = revs.last().unwrap();
    println!(
        "Latest revision new_record={} (it was destroyed): title = {}",
        last.new_record,
        last.attributes.get("title").unwrap()
    );

    Ok(())
}