Skip to main content
Version: 1.2.2

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:

ColumnMeaning
idAudit row PK.
tsUTC timestamp of the write.
tenant_id / user_idFrom AuditConfig.*FromContext (empty if unset).
table_nameThe audited table.
operationcreated / updated / deleted.
pkThe affected row's primary key (composite PKs joined with :).
diffJSON 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

Operationdiff shape
created / deletedThe full row: {"id": 1, "name": "foo", "qty": 3}.
updated via Update / UpdateFieldsThe new values only: {"status": "shipped"} (no prior value — there's no snapshot).
updated via Tracked.SaveA 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.Tx when 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 Update records new values only; the {old, new} delta needs Tracked.Save.
  • No automatic retentionquark_audit grows 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.
JSON numbers

Numbers in diff read back as float64 from a JSON column into a map[string]any — compare accordingly, or decode into a typed struct.