Purge
purgeById physically removes a row that is already soft-deleted. It has its own access rule, its own hooks, and it fails closed on anything still pointing at the row.
A tombstone has to leave the table eventually. Who is allowed to do that, and what stops it happening by accident? Purge answers both, and it answers them separately from delete.
Turn it on for one role
purgeById exists only on a collection with softDelete: true, and its rule
defaults to deny even when delete is wide open.
import { collection } from "#questpie/factories";
export const documents = collection("documents")
.fields(({ f }) => ({
tenantId: f.text().required(),
title: f.text().required(),
}))
.options({ softDelete: true })
.access({
read: true,
create: true,
update: true,
delete: ({ session }) => Boolean(session),
purge: ({ session }) =>
session?.user?.role === "retention-worker"
? { tenantId: session.user.tenantId }
: false,
});Then delete first, purge second:
await ctx.collections.documents.deleteById({ id });
await ctx.collections.documents.purgeById({ id }); // { success: true }The same call from the browser SDK is
client.collections.documents.purgeById({ id }), and that one travels over
POST /api/documents/:id/purge. The server call above runs the CRUD directly.
The three answers it gives
| Result | When |
|---|---|
404 NOT_FOUND | No such row, already purged, rule returned false, or row outside its filter |
409 CONFLICT | The row is still active, something references it, or a lock wait timed out |
501 NOT_IMPLEMENTED | The collection has no softDelete |
Denial and absence look identical on purpose, so a caller cannot probe for rows
it may not touch. It also keeps a retry safe: a worker that lost its
acknowledgement re-runs the call and gets the same not found an already purged
row gives. purgeById throws that, so catch it.
A rule that returns an object is a filter, and the loaded row is tested against
it inside the purge transaction. A row that does not match comes back as the
same not found. The purge filter is stricter than the others: only plain
equality leaves match, so an unknown field or an operator object such as
{ tenantId: { eq: id } } fails closed to not found.
Delete permission is not purge permission
The chain is access.purge, then the app-wide purge default, then deny.
There is no fallback to delete and none to "any signed-in user". System
context bypasses it, as it bypasses every rule. Beyond
CRUD covers where that app-wide slot
lives.
Turn optimisticConcurrency on and purgeById requires expectedRevision
alongside the id, the same as every other write to an existing row.
Two hooks, both fatal
beforePurge and afterPurge run inside the purge transaction. Throwing from
either rolls the whole thing back. Delete hooks do not run at all, so anything
you wrote in afterDelete will not fire here.
collection("documents").hooks({
afterPurge: async ({ original, onAfterCommit, queue }) => {
onAfterCommit(async () => {
await queue.notifyErasure.publish({ tenantId: original.tenantId });
});
},
});Both hooks receive the locked soft-deleted preimage as data and original,
frozen. Writing to it throws. Global hooks run before the collection's own
beforePurge and after its afterPurge.
QUESTPIE re-reads the row after your beforePurge and rejects the purge with a
conflict if it changed, and after afterPurge it checks that no hook put the
owner, its version rows or its locale rows back. Cleaning up related tables
through db is allowed, and the reference scan runs again before the commit so
that cleanup counts. Putting the purged row back is not, and returns a
conflict.
Anything that must not happen after a rollback goes in onAfterCommit, as
above.
Relations fail closed
Any incoming reference blocks the purge with a conflict. Declared scalar
relations, junction rows and real database foreign keys all count, and so do
references from rows that are themselves soft-deleted. Purge never reinterprets
an onDelete: "cascade" declaration as authority to hard-delete the graph, and
it never nullifies a row it is not removing.
To make that check reliable it locks the target table and every referring table that has no physical foreign key, in a deterministic order, before the hooks run. The rescans happen three times: before the hooks, after them, and after the delete.
| What purge does | What you notice |
|---|---|
| Waits at most three seconds for those locks | A blocked purge returns a conflict you can retry |
| Locks the collection's table against itself | Purges of one collection serialize |
| Makes relation writes lock their target row | Extra database work on ordinary creates and updates |
| Rejects a relation to a missing target | 400, so nothing dangling lands behind a purge |
Relations to tables outside the registered app cannot join that protocol. If another system writes those tables, add a real foreign key or accept that purge is not sound for that relation.
What runs around the commit
Every row here is core except the audit record, which @questpie/admin
contributes through its own global hook.
| Integration | On purge |
|---|---|
| Locale rows | Removed with the owner by a database cascade |
| Version rows | Deleted in the same transaction |
| Search | remove after commit, idempotent with the one soft delete already sent |
| Realtime | A delete change carrying no copy of the removed row |
| Uploads | A cleanup intent in the transaction, and a job that deletes the object |
| Audit | A purge record with no label and no field preimage, from the admin |
The typed surfaces follow the capability. purgeById appears on the server
object, the client and the TanStack Query options only for collections with
soft delete on, and OpenAPI emits the route on the same condition.
Next
Retention turns one purge into a scheduled job over a month of tombstones.
Retention
A retention job is application code you write around purgeById. QUESTPIE supplies the index it pages through and the irreversible unit it calls, and nothing else.
Checkpointed seeds
seed.steps() trades one transaction for a checkpoint per step, so a seed that fails on its two hundredth upload resumes there instead of starting over.