# softDelete

> Hide deleted rows everywhere, restore them, and avoid the RLS returning trap.

Source: https://bettersupabase.com/docs/plugins/soft-delete

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

## Where deleted rows are hidden [#where-deleted-rows-are-hidden]

* Reads, counts, `exists` and pagination on the table.
* Updates: an update never touches a deleted row, so `update` returns
  `not_found`.
* Includes: `organizations.findMany({ include: { customers: true } })` leaves
  out deleted customers.
* Relation filters: `{ customers: { some: {...} } }` only considers live rows.
  `every` is rewritten so deleted rows neither satisfy nor fail it.

`withDeleted` and `onlyDeleted` change the queried table only; nested
includes stay filtered.

## Writes [#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`](/docs/specs) 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](/docs/extending/events) are typed
`dev.better-supabase.row.softdeleted`.

## Indexes [#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:

```sql
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:

```sql
create unique index on customers (organization_id, kvk) where archived_at is null;
```

Doctor's [BS218](/docs/cli/doctor#bs218) points at soft-delete tables without
a partial index.

## Deletes and RLS [#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.