auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
# auditlog

An **audit trail for your data models**.

`auditlog` records every create / update / destroy of your models into a single polymorphic
`audits` table, capturing **what** changed (a diff), **who** changed it, **when**, from **where**,
and an optional comment — then lets you query that history and reconstruct any past revision.

`auditlog` is **ORM-agnostic**: implement the small `Auditable` trait for your model and hand it a
`Backend`. A ready-to-use async **`SqlxBackend`** (SQLite & Postgres) is included, plus an in-memory
`MemoryBackend` for tests.

```toml
[dependencies]
auditlog = { version = "0.1", features = ["sqlite"] }   # or "postgres"
```

## Quick start

```rust
use auditlog::{Auditable, AuditOptions, AuditId, ValueMap, SqlxBackend};
use serde_json::json;

struct Post { id: i64, title: String, body: 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
    }
    fn audit_options() -> AuditOptions { AuditOptions::default() }
}

// inside an async fn, returning auditlog::Result<()>:
let backend = SqlxBackend::connect_sqlite("sqlite::memory:").await?;
backend.migrate().await?;

// create
let mut post = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.audited_create(&backend).await?;

// update — diff the old state against the new
let old = Post { id: 1, title: "Hello".into(), body: "...".into() };
post.title = "Hello, world".into();
post.audited_update(&backend, &old).await?;

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

for audit in Post::audits(&backend, 1).await? {
    println!("v{} {} {:?}", audit.version, audit.action, audit.audited_changes);
}
```

You give the crate the **before** and **after** state; it computes the diff, applies your config,
and writes the audit. That is the whole ORM-agnostic contract — call `audited_create` after you
persist, and `audited_update` / `audited_destroy` before.

See [`examples/blog.rs`](examples/blog.rs) for a complete runnable walkthrough
(`cargo run --example blog`).

## Who made the change

Wrap a unit of work in an `as_user` scope. The acting user — and remote address / request id —
live in a `tokio` task-local, so they are isolated per task and restored when the scope ends:

```rust
use auditlog::{as_user, Actor};

// a record user → user_id + user_type
as_user(Actor::record("User", 7), async {
    post.audited_create(&backend).await
}).await?;

// or a plain string → username
as_user(Actor::name("import job"), async {
    post.audited_create(&backend).await
}).await?;
```

For full control (user + IP + request id together, e.g. from web middleware) use
`with_context(AuditContext::new().with_user(..).with_remote_address(..).with_request_uuid(..), fut)`.

## Configuration

All audit options are available via `AuditOptions::builder()`:

| Builder method                          | Option                     | Effect                                                |
| --------------------------------------- | -------------------------- | ----------------------------------------------------- |
| `.only(["a","b"])`                      | `only:`                    | Audit **only** these columns                          |
| `.except(["password"])`                 | `except:`                  | Audit everything **except** these (plus the defaults) |
| `.on([Action::Create, Action::Update])` | `on:`                      | Which actions produce audits                          |
| `.comment_required(true)`               | `comment_required`         | Require a comment (else the op errors / aborts)       |
| `.update_with_comment_only(false)`      | `update_with_comment_only` | A comment alone won't create an update audit          |
| `.max_audits(10)`                       | `max_audits`               | Cap retained audits; older ones are combined          |
| `.redacted(["password"])`               | `redacted:`                | Log that a column changed, but not its value          |
| `.redaction_value(json!("***"))`        | `redaction_value`          | Custom redaction placeholder (default `[REDACTED]`)   |
| `.encrypted(["ssn"])`                   | (encrypted attrs)          | Mask as `[FILTERED]`                                  |
| `.associated_with("Company")`           | `associated_with:`         | Record a parent record on each audit                  |

Instance conditions map to trait methods you override: `audit_if(&self) -> bool` (`if:`) and
`audit_unless(&self) -> bool` (`unless:`). The default-ignored columns
(`id`, `lock_version`, `created_at`, `updated_at`, `created_on`, `updated_on`) and global settings
are configurable via `auditlog::config(|c| ...)`.

The shape of `audited_changes` is:

- **create**`{ "title": "Hello" }` (single values, full filtered snapshot)
- **update**`{ "title": ["Hello", "Hello, world"] }` (`[old, new]` pairs)
- **destroy**`{ "title": "Hello, world", ... }` (single values, full snapshot)

## Querying & scopes

```rust
let all      = Post::audits(&backend, 1).await?;                   // ascending by version
let updates  = Post::query(&backend, 1).updates().fetch().await?; // creates()/updates()/destroys()
let recent   = Post::query(&backend, 1).descending().limit(5).fetch().await?;
let since_v3 = Post::query(&backend, 1).from_version(3).fetch().await?;
```

## Revisions

Reconstruct historical state by folding the change sets:

```rust
let all  = Post::revisions(&backend, 1).await?;          // one per audit
let v2   = Post::revision(&backend, 1, 2).await?;        // None if out of range
let prev = Post::revision_previous(&backend, 1).await?;  // second-most-recent
```

A revision returns the folded attribute map plus a `new_record` flag (`true` when the record was
destroyed at that point — saving it would re-insert the row). Because the crate doesn't own your
persistence, you apply the revision to your own model/ORM. `Audit::undo_plan()` similarly returns
an `UndoPlan` (`Delete` / `Recreate` / `Restore`) describing how to reverse a change.

## Enable / disable

| Scope                                    | API                                                        |
| ---------------------------------------- | ---------------------------------------------------------- |
| Process-global master switch             | `auditlog::set_auditing_enabled(false)`                    |
| Per type (persistent)                    | `Post::disable_auditing()` / `Post::enable_auditing()`     |
| Per scope (task-local, restored on exit) | `without_auditing(fut).await` / `with_auditing(fut).await` |

Effective auditing = global master **and** the type's flag **and** no active `without_auditing`
scope **and** the instance `if`/`unless` checks. `with_auditing` cannot re-enable auditing when the
global master switch is off.

## Backends & migrations

```rust
use auditlog::SqlxBackend;

// convenience (single shared connection — required for :memory:)
let backend = SqlxBackend::connect_sqlite("sqlite://audits.db").await?;
backend.migrate().await?;        // creates the `audits` table + indexes if absent

// or bring your own pool:
// let backend = SqlxBackend::sqlite(my_pool);
// let backend = SqlxBackend::postgres(my_pg_pool);
```

Need a different store (another ORM, a queue, a remote service)? Implement the `Backend` trait —
six methods — and everything else works unchanged. `MemoryBackend` is provided for tests.

### Schema

The `audits` table schema: `auditable_type`/`auditable_id`,
`associated_type`/`associated_id`, `user_type`/`user_id`/`username`, `action`, `audited_changes`
(JSON), `version`, `comment`, `remote_address`, `request_uuid`, `created_at`, with the five
indexes (`auditable_index`, `associated_index`, `user_index`, request-uuid, created-at) plus a
unique index on `(auditable_type, auditable_id, version)` to guard version races. All polymorphic
ids are stored as `TEXT`, so integer **and** UUID primary keys work uniformly.

## Feature flags

- `sqlite` _(default)_`SqlxBackend` on SQLite.
- `postgres``SqlxBackend` on Postgres.

The core (trait, change tracking, context, revisions, `MemoryBackend`) has no DB dependency.

## Design notes

`auditlog`'s behavior is defined in [`docs/SPEC.md`](docs/SPEC.md), the authoritative behavior
specification. A few intentional, idiomatic-Rust design choices:

- **You call the audit methods explicitly** (`audited_create`/`_update`/`_destroy`) — there is no
  single ORM to hook, so audits are recorded by an explicit call rather than an automatic save
  callback. `audited_update` takes the prior state so the crate can diff it.
- **Current user/request context is a `tokio` task-local**, which keeps it isolated per task
  instead of leaking across the pooled threads of an async runtime.
- **`audited_changes` is stored as JSON.**
- **Enum representation is whatever your `audited_attributes()` returns** — there is no global
  enum-introspection step, since the crate doesn't know your column types.
- **`undo` / revisions return a plan / attribute map** for you to apply, rather than mutating your
  records directly.

## Testing

```sh
cargo test                      # 14 unit + 37 behavior + 1 global-state + 7 doc tests (SQLite, no Docker)
cargo test --features pg-tests  # additionally runs tests/postgres.rs against a real Postgres container
cargo run --example blog
```

The `tests/behavior.rs` suite covers the full behavior surface (change-set shapes, versioning,
`on`/`only`/`except`, comments, redaction, `max_audits` combine, user attribution, enable/disable,
revisions, undo, associated audits).

`tests/postgres.rs` exercises the real Postgres backend end-to-end — transactional version
assignment, `$n`-placeholder SQL, JSON `audited_changes` round-trip, RFC 3339 `created_at`
ordering, the `max_audits` combine transaction, and associated audits — using
[`testcontainers`](https://crates.io/crates/testcontainers) to spin up a throwaway Postgres
container. It is gated behind the `pg-tests` feature, so the (heavy) Docker-client dependency is
**only compiled when you opt in**; normal `cargo test` never pulls it. If Docker isn't running the
test prints a SKIP notice and passes, so it is CI-matrix friendly.

## License

Released under the MIT License. See [LICENSE](LICENSE).