Audit
The audit module writes one row for every change to a collection or a global, carrying the actor, the field-level diff and a readable title. It records nothing until you add it.
Someone changed a price last week and nobody remembers who. That is the question this page answers. Add one module and every write starts landing in a collection you can read like any other.
Turn it on
import { adminModule } from "@questpie/admin/modules/admin";
import { auditModule } from "@questpie/admin/modules/audit";
export default [adminModule, auditModule] as const;questpie generate # registers admin_audit_log and a cleanup job
questpie push # creates the table in your dev databaseThat is the whole setup. Nothing has to opt in. The module registers app-level hooks, so they fire on every collection and every global at once.
adminModule is a separate module. Add it too and the log gets a sidebar item
under Administration, showing a read-only list of title, user and date.
What lands in the log
action | Written when |
|---|---|
create | A row is created. |
update | A row changes. No changed field means no entry. |
delete | A row is deleted, soft or hard. |
purge | A soft-deleted row is purged for good. |
transition | A record or a global moves to another workflow stage. |
Globals record update and transition only. A global has no create and no
delete.
Bulk writes are not one entry. updateMany and deleteMany run the write
hooks once per row, so a hundred rows write a hundred entries.
Purge is deliberately thin
A purge entry keeps the action, the id and the actor. Its resourceLabel and
changes are both null. The point of a purge is that the row is gone. The
audit trail must not end up holding the last copy of it.
A failed entry does not fail your write
Each hook catches its own error and calls logger.error. Your mutation still
commits. Purge is the exception. It logs and rethrows, so a purge that cannot
be recorded fails.
The row
admin_audit_log is an ordinary collection with timestamps: true, so
createdAt is when it happened.
| Field | Holds |
|---|---|
action | One of the five above, or whatever a custom entry passed. |
resourceType | collection or global. A custom entry may say anything. |
resource | The key the resource has on app.collections or app.globals. |
resourceId | The row id. Null for a global. |
resourceLabel | The row's own label, cut to 200 characters. |
userId, userName | The actor. |
locale | The locale the write ran under. |
changes | { field: { from, to } }, or null. |
metadata | Always actorType and accessMode, plus what the hook or the caller added. |
title | One sentence, such as Alice updated Posts 'Hello'. |
resourceLabel is the first of _title, title, name, label, slug and
id that holds a non-empty string.
changes and metadata are f.json() columns. Values are coerced before they
are stored, so a Date arrives as an ISO string and a bigint as a string.
The diff leaves out id, createdAt, updatedAt and every key starting with
an underscore.
A global update carries no diff at all. changes is null on every one of them.
You get who and when, not what.
Who the actor is
userName | userId | When |
|---|---|---|
| The user's name, else email, else id | The session user id | A session is present. |
System | system | No session, and the call runs in system mode. |
Anonymous | null | No session, and the call runs in user mode. |
metadata.actorType carries the same three cases as user, system and
anonymous.
The title is rebuilt on read
The stored title is English. An afterRead hook rewrites it in the reader's
locale when the request carries one. The stored row never changes, so a locale
with no messages falls back to what was written.
Skipping a resource
import { collection } from "#questpie/factories";
export const importRuns = collection("importRuns")
.fields(({ f }) => ({
name: f.text(120).label("Name").required(),
payload: f.json().label("Payload"),
}))
.admin({ label: "Import runs", audit: false });audit: false drops that collection out of the log. Leave the key unset and it
is audited. Globals take the same key on their own .admin(). Reach for it on
machine-written tables, heartbeats and caches, the ones that would bury the
entries a person needs to find.
Reading it back
The log is a collection, so query it the way you query your own.
const { docs } = await app.collections.admin_audit_log.find({
where: { resource: "posts", resourceId: id },
orderBy: { createdAt: "desc" },
limit: 20,
});Two REST routes answer the same question for one record.
GET /:collection/:id/audit and GET /globals/:name/audit, both under your
handler's base path. Each takes limit, which defaults to 50, and offset.
Each checks the record exists first, and runs under the caller's own access
rules.
The log answers an anonymous caller
The collection ships access: { read: true }, so GET /api/admin_audit_log
needs no session. Every title, user name and diff is in there. Narrow it
before you ship.
To narrow it, redeclare the collection in your own collections/ directory and
merge the module's builder in. Your file wins, and the merge keeps the fields,
indexes, hooks and admin config. .access() replaces the whole object, so pass
every rule, not just read.
import { auditLogCollection } from "@questpie/admin/modules/audit";
import { collection } from "#questpie/factories";
export const auditLog = collection("admin_audit_log")
.merge(auditLogCollection)
.access({
read: ({ session }) => !!session?.user,
create: false,
update: false,
delete: false,
});Where each topic lives
| Topic | Page |
|---|---|
| One handler across every collection | Configuration |
| The stages these hooks sit on | Hooks |
Why read: true means anyone | Access control |
| Extending a collection a module ships | Modules |
The .admin() call and the rest of its keys | Collections |
| Purge, and what it is allowed to leave behind | Soft delete |
Next
Collections and globals covers the .admin()
call this module extends, and the four methods beside it.
Auth writes that queue a job
withAuthTransactionalQueue commits one Better Auth mutation and one encrypted job dispatch together. The callback gets a transaction-scoped adapter and a publisher that takes only the jobs your app registered.
Overview
Your collections, routes and access rules already describe the app. These pages project that description into an MCP server an agent can call and an OpenAPI document a person can read.