Skip to content

Soft delete

With softDelete on, a delete stamps a column instead of removing the row, and every read filters the stamped rows out. Nothing else about the operation moves: the same permission is required, beforeDelete can still veto it and afterDelete still fires. From the caller’s point of view the row was deleted; how the table records that is this flag’s business.

import { defineCollection } from "@rebasepro/cms-types";
const invoices = defineCollection({
slug: "invoices",
name: "Invoices",
table: "invoices",
softDelete: true,
properties: {
reference: { name: "Reference", type: "string" },
deletedAt: { name: "Deleted at", type: "date", admin: { readOnly: true } }
}
});

true uses deletedAt (column deleted_at). The object form renames it: softDelete: { field: "archivedAt" }.

Postgres only, like search and indexes.

Stamped rows are hidden by default from find, findById, count, the aggregates, the realtime refetch, and from this collection loaded through a relation. That default is the point: code written before the flag existed keeps working, and nobody has to remember to filter.

Two query parameters open it up:

Parameter Answers
?deleted=include Live rows and stamped ones
?deleted=only Stamped rows alone — the trash view

Anything else is a 400 rather than a silent fallback. ?deleted=true quietly hiding every deleted row would look like it worked and answer the opposite question.

A restore is an ordinary update setting the field back to null. There is no special verb, because there is no special state — the row never went anywhere.

A real DELETE is ?hard=true on the delete call. It needs exactly the same permission an ordinary delete does: it is the same verb, and gating it separately would be a second access-control surface for one operation. What it changes is whether the row can come back. Only the literal true or 1 means yes; a typo is a 400, because a caller who asked to purge and got a soft delete believes the data is gone.