QUESTPIE
SchemaSoft delete

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.

View markdown

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.

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:

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

ResultWhen
404 NOT_FOUNDNo such row, already purged, rule returned false, or row outside its filter
409 CONFLICTThe row is still active, something references it, or a lock wait timed out
501 NOT_IMPLEMENTEDThe 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 doesWhat you notice
Waits at most three seconds for those locksA blocked purge returns a conflict you can retry
Locks the collection's table against itselfPurges of one collection serialize
Makes relation writes lock their target rowExtra database work on ordinary creates and updates
Rejects a relation to a missing target400, 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.

IntegrationOn purge
Locale rowsRemoved with the owner by a database cascade
Version rowsDeleted in the same transaction
Searchremove after commit, idempotent with the one soft delete already sent
RealtimeA delete change carrying no copy of the removed row
UploadsA cleanup intent in the transaction, and a job that deletes the object
AuditA 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.

On this page