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/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 text where it has none); you never
hand-write JSONB/BIGSERIAL DDL.
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.
This 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 isn't.
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.