better-drizzle
Plugins

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-drizzle
pnpm add better-drizzle
yarn add better-drizzle
bun add better-drizzle

Usage

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.

OptionDefaultDescription
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
OperationBehavior
findMany, findFirst, findOne, findUnique, count, existsdeleted rows hidden
paginate, cursordeleted rows hidden from data, total, and the navigation flags
update, updateMany, updateEachdeleted rows are not updated
delete, deleteManysoft by default: set column on live rows instead of deleting
upsert, upsertManynot 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 sortsdeleted rows always hidden
connect, disconnect, setdeleted 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 is string, such as a SQLite text column. Otherwise it is a Date.
  • deletedBy is written only when the call passes it and the table has deletedByColumn.
  • restore() and restoreById() exist only on tables with column. They set column (and deletedByColumn, when present) to null through the normal update pipeline with deleted: 'with'. Cache invalidation, timestamps, rules, and client beforeUpdate / afterUpdate hooks run. Both helpers accept select, include, and meta.
  • restoreById(id) matches the table's single primary key column, regardless of its name. It throws INVALID_ARGS for tables with composite keys or no primary key; use restore({ 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() (without keepScopes) 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 passes deleted.

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

On this page