Audit Log
Quark can record an audit trail of every Create, Update, and Delete into a
quark_audit table. Turn it on with Client.EnableAuditLog.
From then on, each write inserts an audit row on the same connection and transaction as the write itself, so the trail is atomic with the data.
err := client.EnableAuditLog(ctx, quark.AuditConfig{
UserFromContext: func(ctx context.Context) string { return userID(ctx) },
TenantFromContext: func(ctx context.Context) string { return tenantID(ctx) },
})
// From here, writes record their own trail:
quark.For[Order](ctx, client).Create(&order) // → one "created" audit row
order.Status = "shipped"
quark.For[Order](ctx, client).Update(&order) // → one "updated" audit row
What gets recorded
EnableAuditLog migrates the quark_audit table (idempotent). Its columns:
| Column | Meaning |
|---|---|
id | Audit row PK. |
ts | UTC timestamp of the write. |
tenant_id / user_id | From AuditConfig.*FromContext (empty if unset). |
table_name | The audited table. |
operation | created / updated / deleted. |
pk | The affected row's primary key (composite PKs joined with :). |
diff | JSON change payload (below). |
The table is created from a model, so its DDL is portable across all six
engines. diff lands in the engine's JSON column, or in text where the engine
has none — you never hand-write JSONB or BIGSERIAL DDL yourself.
The diff payload
| Operation | diff shape |
|---|---|
created / deleted | The full row: {"id": 1, "name": "foo", "qty": 3}. |
updated via Update / UpdateFields | The new values only: {"status": "shipped"} (no prior value — there's no snapshot). |
updated via Tracked.Save | A per-column delta: {"status": {"old": "pending", "new": "shipped"}}. |
To get the {old, new} delta, load through dirty tracking so Quark has the
snapshot:
tracked, _ := quark.For[Order](ctx, client).Track().Find(id)
tracked.Entity.Status = "shipped"
tracked.Save(ctx) // diff = {"status": {"old": "pending", "new": "shipped"}}
Reading the trail
quark_audit is an ordinary table — map it with a model and query it like
anything else:
type AuditEntry struct {
ID int64 `db:"id" pk:"true"`
TS time.Time `db:"ts"`
UserID string `db:"user_id"`
Operation string `db:"operation"`
Table string `db:"table_name"`
PK string `db:"pk"`
}
func (AuditEntry) TableName() string { return "quark_audit" }
// "Who changed this order, and when?"
history, _ := quark.For[AuditEntry](ctx, client).
Where("table_name", "=", "orders").
Where("pk", "=", "42").
OrderBy("ts", "DESC").
List()
fmt.Println(len(history), history[0].Operation)
// => 2 updated
Add a diff quark.JSON[map[string]any] field with db:"diff" when you need the
payload too.
Atomicity — written with the commit, not after
The audit row is inserted inline on the CRUD connection:
- Inside
client.Tx, the audit INSERT joins that transaction — data and trail commit together, or both roll back. You never get committed data without its trail, nor a trail for work that was undone. - Outside a transaction, the audit INSERT is a separate statement right after the
write, with a small crash window between them — wrap writes in
client.Txwhen you need the guarantee.
That is a deliberately stronger guarantee than the Event Bus, whose post-commit emit can be lost on a crash. Losing an event is tolerable; losing an audit record is not.
Choosing which tables to audit
client.EnableAuditLog(ctx, quark.AuditConfig{
IncludeTables: []string{"orders", "payments"}, // only these
// or:
ExcludeTables: []string{"sessions"}, // everything but these
})
ExcludeTables wins over IncludeTables, and quark_audit itself is always
excluded (auditing the audit log would recurse).
Limitations
- Bulk and WHERE-based methods aren't audited (
CreateBatch,UpdateBatch,DeleteBatch,DeleteBy,UpdateMap) — no per-row entity to diff. Loop the single-row methods if you need a trail. - Plain
Updaterecords new values only; the{old, new}delta needsTracked.Save. - No automatic retention —
quark_auditgrows unbounded; schedule your own pruning. - The audit INSERT bypasses the observer/middleware chain (so it never recurses and never shows up in slow-query logs); identifiers are fixed, values are bound — no injection surface.
Numbers in diff read back as float64 from a JSON column into a
map[string]any — compare accordingly, or decode into a typed struct.