277 lines
7.5 KiB
Markdown
277 lines
7.5 KiB
Markdown
# 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<Value>, // 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<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:
|
|
|
|
```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(())
|
|
}
|
|
```
|