softDelete
Hide deleted rows everywhere, restore them, and avoid the RLS returning trap.
import { softDelete } from "better-supabase/plugins/soft-delete";
const db = defineSupabase(schema).use(softDelete()).connect(supabase);
await db.customers.delete(id); // sets archived_at
await db.customers.findById(id); // not_found
await db.customers.findMany({ withDeleted: true });
await db.customers.findMany({ onlyDeleted: true });
await db.customers.restore(id); // clears archived_at
await db.customers.delete(id, { hard: true }); // really deletesWhere deleted rows are hidden
- Reads, counts,
existsand pagination on the table. - Updates: an update never touches a deleted row, so
updatereturnsnot_found. - Includes:
organizations.findMany({ include: { customers: true } })leaves out deleted customers. - Relation filters:
{ customers: { some: {...} } }only considers live rows.everyis rewritten so deleted rows neither satisfy nor fail it.
withDeleted and onlyDeleted change the queried table only; nested
includes stay filtered.
Writes
Set the column through delete and restore only: a create or update that
passes archivedAt fails with invalid_request unless it passes
{ override: true }. An upsert that updates an existing row clears the
column, so upserting a deleted row brings it back.
In a defineApi document the column is readOnly and left
out of the required fields of request bodies. A column without a comment is
described as "Set when the row is deleted. Deleted rows are not returned,
and delete sets this column instead of removing the row."
A soft delete returns no rows, so mutation events and cache invalidation get
the primary key from the where instead: listeners see intent: "softDelete"
and keys, and CloudEvents are typed
dev.better-supabase.row.softdeleted.
Indexes
Every read adds archived_at is null, so index the live rows only. A partial
index skips deleted rows, stays small as they pile up, and serves the filter
the plugin adds:
create index on customers (organization_id, name) where archived_at is null;Put the columns you filter and sort by in the index, as you would without soft delete. A unique constraint that should only hold for live rows becomes a partial unique index:
create unique index on customers (organization_id, kvk) where archived_at is null;Doctor's BS218 points at soft-delete tables without a partial index.
Deletes and RLS
A soft delete is an UPDATE ... SET archived_at = now() without
RETURNING. That matters: a common policy hides deleted rows from SELECT,
and PostgREST checks returned rows against it, so an update that returns the
row fails with forbidden. The plugin never asks for the row back.
better-supabase doctor flags soft-delete tables where this would bite.
Deleting a row that is already deleted returns not_found. { hard: true }
deletes regardless of the column.
Last updated on