@nestbolt/soft-delete
Mixin
Add softDelete(), restore(), forceDelete(), and trashed-state queries directly on your entity instances.
SoftDeletableMixin adds convenience methods to your entity class so you can soft-delete, restore, and inspect entity instances without injecting SoftDeleteService.
Basic Usage
Extend your entity from SoftDeletableMixin():
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import { SoftDeletable, SoftDeletableMixin } from "@nestbolt/soft-delete";
@SoftDeletable()
@Entity("posts")
export class Post extends SoftDeletableMixin(class { id!: string }) {
@PrimaryGeneratedColumn("uuid")
declare id: string;
@Column()
title!: string;
@Column({ name: "deleted_at", type: "datetime", nullable: true, default: null })
deletedAt!: Date | null;
}The base class passed to SoftDeletableMixin() declares the shape that the mixin needs -- specifically that the entity has an id field. Use declare in the extending class to redeclare the TypeORM-decorated id column.
Methods
softDelete()
Sets the deleted-at column to new Date() in the database, then mirrors the change locally on the entity instance:
const post = await repo.findOneByOrFail({ id: "..." });
await post.softDelete();
post.isDeleted(); // true
post.getDeletedAt(); // Daterestore()
Sets the deleted-at column back to NULL in the database, then clears the local property:
await post.restore();
post.isDeleted(); // false
post.getDeletedAt(); // nullforceDelete()
Issues a real DELETE for the row -- the row is physically removed from the table:
await post.forceDelete();After this call the instance is detached from the database; nothing on the in-memory entity changes (its fields still reflect the last loaded state).
isDeleted()
Returns true if the entity has a non-null deleted-at value:
post.isDeleted(); // true after softDelete(), false after restore()isTrashed()
Alias for isDeleted() -- pick whichever reads better in your codebase:
post.isTrashed(); // identical to post.isDeleted()getDeletedAt()
Returns the deleted-at value as a Date | null. If the underlying value is a string (e.g., when SQLite returns ISO strings), it's coerced to a Date:
const when = post.getDeletedAt(); // Date | nullIf SoftDeleteService.getInstance() returns a service, the property name is resolved from the service. If the service is unavailable (e.g., during a unit test that didn't bootstrap the module), the method falls back to reading deletedAt and then deleted_at directly off the instance.
Composing with Other Mixins
SoftDeletableMixin composes with the other Nestbolt mixins like LikeableMixin, HasMediaMixin, and SluggableMixin:
import { HasMedia, HasMediaMixin } from "@nestbolt/medialibrary";
import { Sluggable, SluggableMixin } from "@nestbolt/sluggable";
import { Likeable, LikeableMixin } from "@nestbolt/likeable";
import { SoftDeletable, SoftDeletableMixin } from "@nestbolt/soft-delete";
@Sluggable({ from: "name" })
@SoftDeletable()
@Likeable({ type: "Product" })
@HasMedia({ modelType: "Product" })
@Entity("products")
export class Product extends SoftDeletableMixin(
LikeableMixin(
SluggableMixin(
HasMediaMixin(class {
id!: string;
}),
),
),
) {
@PrimaryGeneratedColumn("uuid")
declare id: string;
@Column()
name!: string;
@Column({ default: "" })
slug!: string;
@Column({ name: "deleted_at", type: "datetime", nullable: true, default: null })
deletedAt!: Date | null;
}
// Product now has all four mixin APIs:
// product.softDelete(), product.restore(), product.isDeleted()
// product.like(userId), product.toggle(userId), product.getLikesCount()
// product.getSlug(), product.findBySlug(), product.regenerateSlug()
// product.addMedia(), product.getMedia(), product.getFirstMediaUrl()The order of mixin wrapping doesn't matter functionally -- pick whatever reads best for your codebase.
How the Bridge Works
The mixin doesn't depend on dependency injection. Instead, it calls SoftDeleteService.getInstance() -- a static accessor that returns the active service instance after SoftDeleteModule has been initialized.
This means:
- The methods work on entity instances loaded via TypeORM, manually constructed, or returned from any source.
- You don't need to pass the service in -- but you do need
SoftDeleteModule.forRoot()to be imported in your application module.
Error Handling
If SoftDeleteModule has not been initialized (e.g., you call a mixin method before the Nest application has bootstrapped), a SoftDeleteNotInitializedException is thrown:
import { SoftDeleteNotInitializedException } from "@nestbolt/soft-delete";
try {
await post.softDelete();
} catch (error) {
if (error instanceof SoftDeleteNotInitializedException) {
console.error("SoftDeleteModule has not been initialized");
}
}getDeletedAt() is the one method that does not throw when the service is unavailable -- it falls back to reading the property directly off the instance, so you can use it safely in tests or scripts.
In practice, the exception should never fire in a running app -- it's a defensive guard for tests or scripts that run outside the normal bootstrap sequence.