@nestbolt/soft-delete
Subscriber
How SoftDeleteSubscriber intercepts repository.remove() calls and the limits of that interception.
SoftDeleteSubscriber is a TypeORM EntitySubscriberInterface that registers itself on the DataSource and intercepts repository.remove() calls for @SoftDeletable() entities. It's wired automatically when you register SoftDeleteModule.forRoot() -- you don't need to register it yourself.
What It Does
When repository.remove(entity) (or manager.remove(entity)) is called, TypeORM fires beforeRemove on every registered subscriber. The soft-delete subscriber checks:
- The event has an
entity(skip otherwise). - The entity has a constructor (skip
Object.create(null)and similar). - The entity's class is decorated with
@SoftDeletable()(skip plain entities). SoftDeleteService.getInstance()is non-null (skip if the module wasn't initialized).- The entity's deleted-at property is currently
null/undefined(skip if it's already soft-deleted -- in that case, the caller is performing an explicit force delete).
If all checks pass, the subscriber sets the deleted-at property to new Date() on the in-memory entity and calls event.manager.save(event.entity) to persist the change.
// Plain TypeORM call
await repo.remove(post);
// Becomes (effectively)
post.deletedAt = new Date();
await manager.save(post);The original DELETE issued by TypeORM still runs after the subscriber returns -- see Limits for what that means in practice.
When You Want This
The subscriber is convenient for code that already uses TypeORM's remove() API -- existing services and CRUD endpoints don't need to change to start soft-deleting:
// Existing service code -- no changes needed
@Injectable()
export class PostsService {
constructor(@InjectRepository(Post) private readonly repo: Repository<Post>) {}
delete(id: string) {
return this.repo.remove({ id } as Post);
}
}If Post is decorated with @SoftDeletable(), the call above turns into a soft delete. If not, it remains a real delete.
Limits
This is a best-effort interception, not a hard guarantee:
- TypeORM's subscriber model cannot truly cancel a remove. After
beforeRemovereturns, TypeORM continues with the originalDELETESQL. The subscriber relies on the row being saved with the newdeletedAtfirst, then the subsequentDELETEgoing through normally for the in-memory entity copy that no longer matches the row -- behavior depends on TypeORM internals and database driver specifics. - Bulk deletes via
repo.delete(criteria)orcreateQueryBuilder().delete()do not trigger entity subscribers, so they always perform a real delete and bypass interception entirely. - If
event.manager.save()throws, the subscriber swallows the error so the original remove path isn't crashed by a failed interception attempt -- this means a transient database failure can result in a real delete instead of a soft delete.
For reliable soft-delete behavior, prefer the explicit API:
// Always soft-deletes
await softDeleteService.softDelete(Post, post.id);
// Or via the mixin
await post.softDelete();Already-Soft-Deleted Entities
If you call repo.remove(post) on an entity that has already been soft-deleted (post.deletedAt is set), the subscriber returns early and lets the DELETE proceed. This is the canonical way to perform a force delete via the repository.remove() API:
const post = await repo.findOneByOrFail({ id });
await post.softDelete(); // post.deletedAt is now a Date
await repo.remove(post); // subscriber skips -- row is physically removedEquivalent to calling softDeleteService.forceDelete(Post, post.id) directly, but available through the standard repository API.
Disabling Interception
There's no opt-out flag. If you don't want repo.remove() interception for a specific entity, don't decorate it with @SoftDeletable(). The subscriber only acts on decorated entities; everything else passes through untouched.