Soft delete
Turn delete() into a recoverable state change, filter deleted rows by default, and add restore helpers.
better-drizzle/soft-delete extends the built-in methods with typed soft-delete controls, default visibility filtering, and restore helpers.
Install
npm install better-drizzlepnpm add better-drizzleyarn add better-drizzlebun add better-drizzleUsage
import { better } from 'better-drizzle';
import { softDelete } from 'better-drizzle/soft-delete';
const client = better(db, {
plugins: [
softDelete({
column: 'deletedAt',
deletedByColumn: 'deletedById',
defaults: {
mode: 'soft',
visibility: 'without',
},
}),
],
});Options
Every option is optional. Column names are the property keys in your Drizzle table definition, not the database column names.
| Option | Default | Description |
|---|---|---|
column | 'deletedAt' | column that marks a row as deleted; tables without it are ignored |
deletedByColumn | 'deletedById' | column written from the deletedBy arg and cleared on restore, when the table has it |
defaults.mode | 'soft' | delete() mode when the call passes no mode |
defaults.visibility | 'without' | read visibility when the call passes no deleted: 'without' or 'with' |
What is filtered
Added in 0.4.0| Operation | Behavior |
|---|---|
findMany, findFirst, findOne, findUnique, count, exists | deleted rows hidden |
paginate, cursor | deleted rows hidden from data, total, and the navigation flags |
update, updateMany, updateEach | deleted rows are not updated |
delete, deleteMany | soft by default: set column on live rows instead of deleting |
upsert, upsertMany | not filtered: hiding a deleted row would turn the update into a duplicate insert |
include, relation select, relation filters (some, every, none, is, isNot), count, relation sorts | deleted rows always hidden |
connect, disconnect, set | deleted rows cannot be linked or unlinked |
$where() | deleted rows hidden: the condition includes the visibility filter |
The reads, updates, and deletes in the first rows accept deleted: 'without' | 'with' | 'only'. It controls the root rows only; nested reads always hide deleted rows. A write whose where is empty stays a no-op: the plugin never turns deleteMany({}) into "soft delete everything".
await client.users.findMany(); // visible rows only
await client.users.findMany({ deleted: 'with' }); // include deleted
await client.users.findMany({ deleted: 'only' }); // only deleted
await client.users.count({ deleted: 'with' });Changed in 0.4.0
The plugin now filters through core model
scopes. Before 0.4.0 it only
filtered the root query, so deleted rows appeared in include, relation
select, relation filters, count, and relation sorts. They are hidden
there now, and connect/set no longer reach deleted rows. Read deleted
relations from the root with deleted: 'with', or through
$withoutPlugins().
Protected columns
Ordinary writes ignore caller-provided deletedAt and deletedById values, or the configured column and deletedByColumn. This applies to create, createMany, update, updateMany, updateEach, upsert, and upsertMany. The plugin removes those fields from updateEach resolvers and from every upsertMany update shape: 'all', a column list, an object, or a callback result. Expanding 'all' also preserves the conflict target.
Upserts can update other fields on a deleted row, but keep its deletion timestamp and author. Use delete() to mark a row as deleted and restore() to clear those fields. For trusted imports that need to set them directly, use $withoutPlugins().
Deleting and restoring
// Soft delete (default)
await client.users.delete({ where: { id: 1 } });
// Record who deleted it (when deletedByColumn exists)
await client.users.delete({ where: { id: 1 }, deletedBy: 'admin_42' });
// Soft delete many rows; already deleted rows keep their timestamp
await client.users.deleteMany({ where: { active: false } });
// Force a real, physical delete
await client.users.delete({ where: { id: 1 }, mode: 'hard' });
// Purge rows that were soft deleted
await client.users.deleteMany({ where: { id: { in: ids } }, mode: 'hard', deleted: 'only' });
// Restore
await client.users.restore({ where: { id: 1 } });
await client.users.restoreById(1);A soft delete() runs an update of the same where limited to live rows, then returns the updated row (or null), so it still has .throw(). A soft deleteMany() runs an updateMany and returns { count, data? }: PostgreSQL and SQLite include the marked rows (or scalar select fields), while MySQL returns only the count. Empty results omit data. Deleting an already deleted row finds nothing and keeps the original timestamp.
Behavior details
- The timestamp is an ISO 8601 string (
new Date().toISOString()) when the column's Drizzle data type isstring, such as a SQLitetextcolumn. Otherwise it is aDate. deletedByis written only when the call passes it and the table hasdeletedByColumn.restore()andrestoreById()exist only on tables withcolumn. They setcolumn(anddeletedByColumn, when present) tonullthrough the normal update pipeline withdeleted: 'with'. Cache invalidation, timestamps, rules, and clientbeforeUpdate/afterUpdatehooks run. Both helpers acceptselect,include, andmeta.restoreById(id)matches the table's single primary key column, regardless of its name. It throwsINVALID_ARGSfor tables with composite keys or no primary key; userestore({ where })for those tables.- A restore keeps model scopes, so a tenant-scoped client only restores that tenant's rows. A soft delete uses an inner
$withoutPlugins({ keepScopes: true }).update(...), which keeps scopes and client update hooks but skips other plugins; its update hooks run before the delete hooks. $withoutPlugins()(withoutkeepScopes) drops the soft-delete filter with every other scope, so it reads deleted rows at every level and deletes physically.mode: 'hard'falls back to the built-in physical delete. It matches deleted and live rows alike unless the call passesdeleted.
Need a one-off hard delete elsewhere?
Any delegate can bypass plugins for a single call with
$withoutPlugins() - handy
for repair scripts that must delete beneath the soft-delete layer.
Where it fits best
- audit-sensitive systems
- admin tools where deleted rows must remain inspectable
- apps where "delete" should default to reversible