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, {
	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.

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

OperationBehavior
findMany, findFirst, count, existsdeleted rows hidden; accept deleted: 'without' | 'with' | 'only'
findOne, findUnique, paginate, cursornot filtered, deleted rows are returned
update, updateMany, updateEach, upsert, upsertManynot filtered, deleted rows can be updated
deletesoft by default: sets column instead of deleting
deleteManyalways a hard delete
relations loaded with include or a relation selectnot 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 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 $withoutPlugins().update(...). Both accept select, include, and meta.
  • restoreById(id) matches the id column. Tables whose primary key is named differently should use restore({ where }).
  • The inner update runs through $withoutPlugins(), so other plugins (such as timestamps) do not see it. Client beforeUpdate / afterUpdate hooks 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

On this page