NestboltNestbolt

@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();   // Date

restore()

Sets the deleted-at column back to NULL in the database, then clears the local property:

await post.restore();

post.isDeleted();      // false
post.getDeletedAt();   // null

forceDelete()

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 | null

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