QUESTPIE

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.

View markdown

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

src/questpie/server/modules.ts
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 database

That 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

actionWritten when
createA row is created.
updateA row changes. No changed field means no entry.
deleteA row is deleted, soft or hard.
purgeA soft-deleted row is purged for good.
transitionA 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.

FieldHolds
actionOne of the five above, or whatever a custom entry passed.
resourceTypecollection or global. A custom entry may say anything.
resourceThe key the resource has on app.collections or app.globals.
resourceIdThe row id. Null for a global.
resourceLabelThe row's own label, cut to 200 characters.
userId, userNameThe actor.
localeThe locale the write ran under.
changes{ field: { from, to } }, or null.
metadataAlways actorType and accessMode, plus what the hook or the caller added.
titleOne 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

userNameuserIdWhen
The user's name, else email, else idThe session user idA session is present.
SystemsystemNo session, and the call runs in system mode.
AnonymousnullNo 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

src/questpie/server/collections/import-runs.ts
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.

src/questpie/server/collections/audit-log.ts
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

TopicPage
One handler across every collectionConfiguration
The stages these hooks sit onHooks
Why read: true means anyoneAccess control
Extending a collection a module shipsModules
The .admin() call and the rest of its keysCollections
Purge, and what it is allowed to leave behindSoft delete

Next

Collections and globals covers the .admin() call this module extends, and the four methods beside it.

On this page