sqlx-record/.claude/skills/sqlx-audit.md

7.5 KiB

sqlx-record Audit Trail Skill

Guide to change tracking and audit functionality.

Triggers

  • "audit trail", "change tracking"
  • "entity change", "change history"
  • "who changed", "audit log"

EntityChange Model

pub struct EntityChange {
    pub id: Uuid,              // Change record ID
    pub entity_id: Uuid,       // Target entity ID
    pub action: String,        // Change type
    pub changed_at: i64,       // Timestamp (milliseconds since epoch)
    pub actor_id: Uuid,        // Who made the change
    pub session_id: Uuid,      // Session context
    pub change_set_id: Uuid,   // Transaction grouping
    pub new_value: Option<Value>, // JSON diff of changes
}

Action Enum

pub enum Action {
    Insert,      // New entity created
    Update,      // Entity modified
    Delete,      // Soft delete
    Restore,     // Restored from soft delete
    HardDelete,  // Permanent deletion
    Unknown(String),
}

// String conversion
let action = Action::from("insert".to_string());  // Action::Insert
println!("{}", Action::Update);  // "update"

Repository Functions

use sqlx_record::repositories::*;

// Create a change record
create_entity_change(&pool, "entity_changes_users", &change).await?;

// Query by change ID
let changes = get_entity_changes_by_id(&pool, table, &change_id).await?;

// Query by entity (all changes to an entity)
let history = get_entity_changes_by_entity(&pool, table, &entity_id).await?;

// Query by session (all changes in a session)
let changes = get_entity_changes_session(&pool, table, &session_id).await?;

// Query by actor (all changes by a user)
let changes = get_entity_changes_actor(&pool, table, &actor_id).await?;

// Query by change set (atomic transaction)
let changes = get_entity_changes_by_change_set(&pool, table, &change_set_id).await?;

// Combined query
let changes = get_entity_changes_by_entity_and_actor(&pool, table, &entity_id, &actor_id).await?;

Diff Methods

model_diff

Compare UpdateForm with existing model:

let form = User::update_form().with_name("New Name").with_email("new@example.com");
let existing = User::get_by_id(&pool, &id).await?.unwrap();

let diff = User::model_diff(&form, &existing);
// {"name": {"old": "Old Name", "new": "New Name"}, "email": {"old": "old@example.com", "new": "new@example.com"}}

db_diff

Compare UpdateForm with database values:

let form = User::update_form().with_name("New Name");
let diff = User::db_diff(&form, &user_id, &pool).await?;

diff_modify

Modify form to only include actual changes:

let mut form = User::update_form().with_name("Same Name").with_email("new@example.com");
let existing = User::get_by_id(&pool, &id).await?.unwrap();

let diff = User::diff_modify(&mut form, &existing);
// form now only contains email if name was already "Same Name"
// diff contains only the actual changes

initial_diff

Capture initial state for inserts:

let new_user = User { id: new_uuid(), name: "Alice".into(), ... };
let diff = new_user.initial_diff();
// {"id": "...", "name": "Alice", ...}

to_update_form

Convert entity to UpdateForm:

let user = User::get_by_id(&pool, &id).await?.unwrap();
let form = user.to_update_form();
// form has all fields set to current values

Audit Table Setup

Use sqlx-record-ctl CLI:

sqlx-record-ctl --schema-name mydb --db-url "mysql://user:pass@localhost/mydb"

Or create manually:

-- Metadata table (required)
CREATE TABLE entity_changes_metadata (
    table_name VARCHAR(255) PRIMARY KEY,
    is_auditable BOOLEAN NOT NULL DEFAULT TRUE
);

INSERT INTO entity_changes_metadata VALUES ('users', TRUE);

-- Audit table
CREATE TABLE entity_changes_users (
    id BINARY(16) PRIMARY KEY,
    entity_id BINARY(16) NOT NULL,
    action ENUM('insert','update','delete','restore','hard-delete'),
    changed_at BIGINT,
    actor_id BINARY(16),
    session_id BINARY(16),
    change_set_id BINARY(16),
    new_value JSON
);

-- Indexes
CREATE INDEX idx_users_entity_id ON entity_changes_users (entity_id);
CREATE INDEX idx_users_actor_id ON entity_changes_users (actor_id);
CREATE INDEX idx_users_session_id ON entity_changes_users (session_id);
CREATE INDEX idx_users_change_set_id ON entity_changes_users (change_set_id);
CREATE INDEX idx_users_entity_id_actor_id ON entity_changes_users (entity_id, actor_id);

Complete Audit Integration

use sqlx_record::prelude::*;
use sqlx_record::models::{EntityChange, Action};
use sqlx_record::repositories::create_entity_change;

async fn create_user_with_audit(
    pool: &Pool,
    user: User,
    actor_id: &Uuid,
    session_id: &Uuid,
    change_set_id: &Uuid,
) -> Result<Uuid, Error> {
    // Capture initial state
    let diff = user.initial_diff();

    // Insert entity
    let user_id = user.insert(pool).await?;

    // Record change
    let change = EntityChange {
        id: new_uuid(),
        entity_id: user_id,
        action: Action::Insert.to_string(),
        changed_at: chrono::Utc::now().timestamp_millis(),
        actor_id: *actor_id,
        session_id: *session_id,
        change_set_id: *change_set_id,
        new_value: Some(serde_json::to_value(diff).unwrap()),
    };

    create_entity_change(pool, &User::entity_changes_table_name(), &change).await?;

    Ok(user_id)
}

async fn update_user_with_audit(
    pool: &Pool,
    user_id: &Uuid,
    form: UserUpdateForm,
    actor_id: &Uuid,
    session_id: &Uuid,
    change_set_id: &Uuid,
) -> Result<(), Error> {
    // Get current state
    let existing = User::get_by_id(pool, user_id).await?.unwrap();

    // Calculate diff
    let diff = User::model_diff(&form, &existing);

    // Skip if no changes
    if diff.as_object().map(|o| o.is_empty()).unwrap_or(true) {
        return Ok(());
    }

    // Update entity
    User::update_by_id(pool, user_id, form).await?;

    // Record change
    let change = EntityChange {
        id: new_uuid(),
        entity_id: *user_id,
        action: Action::Update.to_string(),
        changed_at: chrono::Utc::now().timestamp_millis(),
        actor_id: *actor_id,
        session_id: *session_id,
        change_set_id: *change_set_id,
        new_value: Some(diff),
    };

    create_entity_change(pool, &User::entity_changes_table_name(), &change).await?;

    Ok(())
}

// Query history
async fn get_user_history(pool: &Pool, user_id: &Uuid) -> Result<Vec<EntityChange>, Error> {
    get_entity_changes_by_entity(pool, "entity_changes_users", user_id).await
}

// Query by actor
async fn get_changes_by_user(pool: &Pool, actor_id: &Uuid) -> Result<Vec<EntityChange>, Error> {
    get_entity_changes_actor(pool, "entity_changes_users", actor_id).await
}

Change Set Pattern

Group related changes into atomic transactions:

async fn transfer_ownership(
    pool: &Pool,
    item_id: &Uuid,
    from_user: &Uuid,
    to_user: &Uuid,
    actor_id: &Uuid,
    session_id: &Uuid,
) -> Result<(), Error> {
    // Single change set for the transaction
    let change_set_id = new_uuid();

    // Update item ownership
    update_with_audit(pool, item_id,
        Item::update_form().with_owner_id(*to_user),
        actor_id, session_id, &change_set_id
    ).await?;

    // Update from_user stats
    update_with_audit(pool, from_user,
        UserStats::update_form().with_item_count_delta(-1),
        actor_id, session_id, &change_set_id
    ).await?;

    // Update to_user stats
    update_with_audit(pool, to_user,
        UserStats::update_form().with_item_count_delta(1),
        actor_id, session_id, &change_set_id
    ).await?;

    // All three changes can be queried together via change_set_id
    Ok(())
}