# Purge (/docs/schema/soft-delete/purge)

---
title: Purge
description: 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.
kind: guide
package: questpie
---

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.

```ts title="src/questpie/server/collections/documents.ts"
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:

```ts
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.

<Callout type="warn" title="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](/docs/schema/access-control/beyond-crud) covers where that app-wide slot
	lives.
</Callout>

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.

```ts
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](/docs/schema/soft-delete/retention)** turns one purge into a
scheduled job over a month of tombstones.
