7.5 KiB
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(())
}