NestboltNestbolt

@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:

  1. The event has an entity (skip otherwise).
  2. The entity has a constructor (skip Object.create(null) and similar).
  3. The entity's class is decorated with @SoftDeletable() (skip plain entities).
  4. SoftDeleteService.getInstance() is non-null (skip if the module wasn't initialized).
  5. 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 beforeRemove returns, TypeORM continues with the original DELETE SQL. The subscriber relies on the row being saved with the new deletedAt first, then the subsequent DELETE going 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) or createQueryBuilder().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 removed

Equivalent 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.