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, {
schema,
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
| Operation | Behavior |
|---|---|
findMany, findFirst, count, exists | deleted rows hidden; accept deleted: 'without' | 'with' | 'only' |
findOne, findUnique, paginate, cursor | not filtered, deleted rows are returned |
update, updateMany, updateEach, upsert, upsertMany | not filtered, deleted rows can be updated |
delete | soft by default: sets column instead of deleting |
deleteMany | always a hard delete |
relations loaded with include or a relation select | not filtered |
Add { deletedAt: null } to the where yourself for the operations that are not filtered.
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' });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' });
// Force a real, physical delete
await client.users.delete({ where: { id: 1 }, mode: 'hard' });
// Restore
await client.users.restore({ where: { id: 1 } });
await client.users.restoreById(1);A soft delete() runs an update of the same where, then returns the updated row (or null), so it still has .throw(). Its where is not filtered, so deleting an already deleted row sets a new 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$withoutPlugins().update(...). Both acceptselect,include, andmeta.restoreById(id)matches theidcolumn. Tables whose primary key is named differently should userestore({ where }).- The inner update runs through
$withoutPlugins(), so other plugins (such as timestamps) do not see it. ClientbeforeUpdate/afterUpdatehooks still fire for it, before the delete hooks. mode: 'hard'falls back to the built-in physical delete.
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