# 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 ```rust 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, // JSON diff of changes } ``` ## Action Enum ```rust 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 ```rust 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: ```rust 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: ```rust 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: ```rust 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: ```rust 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: ```rust 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: ```bash sqlx-record-ctl --schema-name mydb --db-url "mysql://user:pass@localhost/mydb" ``` Or create manually: ```sql -- 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 ```rust 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 { // 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, 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, Error> { get_entity_changes_actor(pool, "entity_changes_users", actor_id).await } ``` ## Change Set Pattern Group related changes into atomic transactions: ```rust 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(()) } ```