Skip to content

SoftDeletableRepository

Overview

SoftDeletableRepository is a base repository class that implements the soft-delete pattern. Instead of physically removing records from the database, delete operations set a deletedAt timestamp. Records can later be restored or permanently hard-deleted.

All business entity repositories in the BANA system extend this class.

Source: SoftDeletableRepository is re-exported from @venizia/ignis via packages/core/src/base/repository/index.ts — it is no longer defined inside core. Import it as import { SoftDeletableRepository } from '@nx/core';. The same barrel also exports ArchivedRepository (a SoftDeletableRepository subclass adding a status-based archive lifecycle), defined in packages/core/src/base/repository/archived.repository.ts.

See the IGNIS Repositories reference for details on SoftDeletableRepository and its parent DefaultCRUDRepository class. The API and patterns documented below remain accurate.

Class Definition

typescript
import { DefaultCRUDRepository, IExtraOptions, TTableObject, TTableInsert } from '@venizia/ignis';

export type TSoftDeletableTableSchema = TTableSchemaWithId & {
  deletedAt: AnyPgColumn<{ data: Date | null }>;
};

export class SoftDeletableRepository<
  EntitySchema extends TSoftDeletableTableSchema,
  DataObject extends TTableObject<EntitySchema> = TTableObject<EntitySchema>,
  PersistObject extends TTableInsert<EntitySchema> = TTableInsert<EntitySchema>,
  ExtraOptions extends IExtraOptions = IExtraOptions,
> extends DefaultCRUDRepository<EntitySchema, DataObject, PersistObject, ExtraOptions> {

  // Read (overridden)
  findById(opts): Promise<R | null>;       // with isStrict option

  // Delete (overridden — soft delete by default)
  deleteById(opts): Promise<TCount & { data: R }>;
  deleteAll(opts): Promise<TCount & { data: R[] }>;
  deleteBy(opts): Promise<TCount & { data: R[] }>;

  // Restore (new methods)
  restoreById(opts): Promise<TCount & { data: R }>;
  restoreAll(opts): Promise<TCount & { data: R[] }>;
  restoreBy(opts): Promise<TCount & { data: R[] }>;
}

Inheritance Hierarchy

Record State Diagram

Soft Delete Flow

Model Requirements

For a model to work with SoftDeletableRepository, it must meet three requirements:

1. Schema must include deletedAt column

typescript
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { generateCommonColumnDefs } from '@nx/core';

export const CategorySchema = pgTable('Category', {
  ...generateCommonColumnDefs(), // includes id, createdAt, modifiedAt, deletedAt
  name: text('name').notNull(),
  // ...
});

2. Model must set defaultFilter to exclude deleted records

typescript
import { BaseEntity, model } from '@venizia/ignis';

@model({
  type: 'entity',
  settings: {
    defaultFilter: { where: { deletedAt: null } },
    hiddenProperties: ['createdAt', 'modifiedAt', 'deletedAt'],
  },
})
export class Category extends BaseEntity<typeof CategorySchema> {
  static override TABLE_NAME = 'Category';
  static override schema = CategorySchema;
}

3. Repository must extend SoftDeletableRepository

typescript
import { repository } from '@venizia/ignis';
import { SoftDeletableRepository } from '@nx/core';
import { PostgresCoreDataSource } from '@/datasources';

@repository({ dataSource: PostgresCoreDataSource, model: Category })
export class CategoryRepository extends SoftDeletableRepository<
  typeof CategorySchema,
  TCategory,
  TCategoryPersist
> {}

API Reference

Read Operations

findById

Overrides the parent method to add strict mode. When isStrict: true, a 404 Not Found error is thrown if the record does not exist.

typescript
// Non-strict (default) — returns null if not found
const product = await repository.findById({
  id: 'product-123',
});
// Returns: TProduct | null

// Strict mode — throws 404 if not found
const product = await repository.findById({
  id: 'product-123',
  options: { isStrict: true },
});
// Returns: TProduct (or throws HTTP 404)

Method signatures:

typescript
// Non-strict overload
findById<R = DataObject>(opts: {
  id: IdType;
  filter?: Omit<TFilter<DataObject>, 'where'>;
  options?: ExtraOptions & { isStrict?: false };
}): Promise<TNullable<R>>;

// Strict overload
findById<R = DataObject>(opts: {
  id: IdType;
  filter?: Omit<TFilter<DataObject>, 'where'>;
  options?: ExtraOptions & { isStrict?: true };
}): Promise<R>;

Delete Operations

All delete methods perform soft delete by default (setting deletedAt = new Date()). Pass shouldHardDelete: true to permanently remove the record instead.

deleteById

Soft-deletes a single record by ID.

typescript
// Soft delete (default)
const result = await repository.deleteById({
  id: 'product-123',
});
// result: { count: 1, data: { id: 'product-123', deletedAt: '2026-01-20T...' } }

// Without returning data
const result = await repository.deleteById({
  id: 'product-123',
  options: { shouldReturn: false },
});
// result: { count: 1, data: null }

// Hard delete (permanent removal)
const result = await repository.deleteById({
  id: 'product-123',
  options: { shouldHardDelete: true },
});

Method signatures:

typescript
deleteById(opts: {
  id: IdType;
  options: ExtraOptions & { shouldReturn: false; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;

deleteById<R = DataObject>(opts: {
  id: IdType;
  options?: ExtraOptions & { shouldReturn?: true; shouldHardDelete?: boolean };
}): Promise<TCount & { data: R }>;

deleteAll

Soft-deletes all records matching a where filter.

typescript
// Soft delete by filter
const result = await repository.deleteAll({
  where: { merchantId: 'merchant-123' },
});
// result: { count: 5, data: [...softDeletedRecords] }

// Without filter (requires force: true)
const result = await repository.deleteAll({
  where: {},
  options: { force: true },
});

// Hard delete
const result = await repository.deleteAll({
  where: { status: 'archived' },
  options: { shouldHardDelete: true },
});

Method signatures:

typescript
deleteAll(opts: {
  where?: TWhere<DataObject>;
  options: ExtraOptions & { shouldReturn: false; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;

deleteAll<R = DataObject>(opts: {
  where?: TWhere<DataObject>;
  options?: ExtraOptions & { shouldReturn?: true; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: Array<R> }>;

deleteBy

Soft-deletes records matching specific conditions. Identical behavior to deleteAll but the where parameter is required.

typescript
const result = await repository.deleteBy({
  where: { status: 'inactive', merchantId: 'merchant-123' },
});

Method signatures:

typescript
deleteBy(opts: {
  where: TWhere<DataObject>;
  options: ExtraOptions & { shouldReturn: false; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: undefined | null }>;

deleteBy<R = DataObject>(opts: {
  where: TWhere<DataObject>;
  options?: ExtraOptions & { shouldReturn?: true; force?: boolean; shouldHardDelete?: boolean };
}): Promise<TCount & { data: Array<R> }>;

Restore Operations

Restore methods set deletedAt = null on soft-deleted records. They use shouldSkipDefaultFilter: true internally so they can find records that were excluded by the defaultFilter.

restoreById

Restores a single soft-deleted record by ID.

typescript
const result = await repository.restoreById({
  id: 'product-123',
});
// result: { count: 1, data: { id: 'product-123', deletedAt: null, ... } }

// Without returning data
const result = await repository.restoreById({
  id: 'product-123',
  options: { shouldReturn: false },
});

Method signature:

typescript
restoreById<R = DataObject>(opts: {
  id: IdType;
  options?: ExtraOptions & { shouldReturn?: boolean };
}): Promise<TCount & { data: TNullable<R> }>;

restoreAll

Restores all soft-deleted records matching a filter.

typescript
const result = await repository.restoreAll({
  where: { merchantId: 'merchant-123' },
});
// Restores all soft-deleted records for that merchant

// Without filter (requires force: true)
const result = await repository.restoreAll({
  where: {},
  options: { force: true },
});

Method signature:

typescript
restoreAll<R = DataObject>(opts: {
  where?: TWhere<DataObject>;
  options?: ExtraOptions & { shouldReturn?: boolean; force?: boolean };
}): Promise<TCount & { data: TNullable<Array<R>> }>;

restoreBy

Delegates to restoreAll. Provided for API consistency with deleteBy.

typescript
const result = await repository.restoreBy({
  where: { merchantId: 'merchant-123', status: 'active' },
});

Method signature:

typescript
restoreBy<R = DataObject>(opts: {
  where: TWhere<DataObject>;
  options?: ExtraOptions & { shouldReturn?: boolean; force?: boolean };
}): Promise<TCount & { data: TNullable<Array<R>> }>;

API Summary

MethodDefault BehaviorKey Options
findByIdReturns null if not foundisStrict: true throws 404
deleteByIdSets deletedAt = new Date()shouldHardDelete: true for permanent removal
deleteAllSets deletedAt = new Date()shouldHardDelete, force
deleteBySets deletedAt = new Date()shouldHardDelete, force
restoreByIdSets deletedAt = nullshouldReturn
restoreAllSets deletedAt = nullshouldReturn, force
restoreByDelegates to restoreAllshouldReturn, force

Usage Examples

Complete Repository Definition

typescript
import { repository } from '@venizia/ignis';
import { SoftDeletableRepository } from '@nx/core';
import { PostgresCoreDataSource } from '@/datasources';
import { Category, TCategory, TCategoryPersist, TCategorySchema } from '@nx/core/models';

@repository({ dataSource: PostgresCoreDataSource, model: Category })
export class CategoryRepository extends SoftDeletableRepository<
  TCategorySchema,
  TCategory,
  TCategoryPersist
> {
  // Custom query — defaultFilter automatically excludes deleted records
  async findByMerchant(merchantId: string): Promise<TCategory[]> {
    return this.find({
      filter: { where: { merchantId } },
    });
  }

  // Query including soft-deleted records
  async findAllIncludingDeleted(merchantId: string): Promise<TCategory[]> {
    return this.find({
      filter: { where: { merchantId } },
      options: { shouldSkipDefaultFilter: true },
    });
  }
}

Service with Soft Delete

typescript
import { inject, BaseService } from '@venizia/ignis';

export class CategoryService extends BaseService {
  constructor(
    @inject({ key: 'repositories.CategoryRepository' })
    private categoryRepository: CategoryRepository,
  ) {
    super({ scope: CategoryService.name });
  }

  async deleteCategory(id: string) {
    // Soft delete — sets deletedAt, record remains in DB
    return this.categoryRepository.deleteById({ id });
  }

  async restoreCategory(id: string) {
    // Restore — clears deletedAt
    return this.categoryRepository.restoreById({ id });
  }

  async permanentlyDelete(id: string) {
    // Hard delete — physically removes the record
    return this.categoryRepository.deleteById({
      id,
      options: { shouldHardDelete: true },
    });
  }

  async getCategoryOrFail(id: string) {
    // Strict mode — throws 404 if not found or soft-deleted
    return this.categoryRepository.findById({
      id,
      options: { isStrict: true },
    });
  }
}

Transaction with Soft Delete

typescript
await this.categoryRepository.dataSource.withTransaction(async (tx) => {
  // Soft delete parent
  await this.categoryRepository.deleteById({
    id: parentId,
    options: { transaction: tx },
  });

  // Soft delete all children
  await this.categoryRepository.deleteAll({
    where: { parentId },
    options: { transaction: tx },
  });
});

Query Behavior

Default Filter

When defaultFilter: { where: { deletedAt: null } } is configured on the model, all queries automatically exclude soft-deleted records:

typescript
// Automatically excludes deleted records
const categories = await repository.find({
  filter: { where: { merchantId: 'merchant-123' } },
});
// SQL: SELECT * FROM "Category" WHERE merchant_id = ? AND deleted_at IS NULL

Bypassing the Default Filter

Use shouldSkipDefaultFilter: true to include soft-deleted records:

typescript
// Includes all records regardless of deletedAt
const all = await repository.find({
  filter: { where: { merchantId: 'merchant-123' } },
  options: { shouldSkipDefaultFilter: true },
});
// SQL: SELECT * FROM "Category" WHERE merchant_id = ?

Querying Only Deleted Records

typescript
const deleted = await repository.find({
  filter: {
    where: {
      merchantId: 'merchant-123',
      deletedAt: { neq: null },
    },
  },
  options: { shouldSkipDefaultFilter: true },
});

Best Practices

1. Always extend SoftDeletableRepository for business entities

typescript
// Correct
@repository({ dataSource: PostgresCoreDataSource, model: Product })
export class ProductRepository extends SoftDeletableRepository<...> {}

// Avoid — no soft-delete, no restore, no isStrict
@repository({ dataSource: PostgresCoreDataSource, model: Product })
export class ProductRepository extends DefaultCRUDRepository<...> {}

2. Always configure defaultFilter on the model

typescript
@model({
  type: 'entity',
  settings: {
    defaultFilter: { where: { deletedAt: null } },
    hiddenProperties: ['createdAt', 'modifiedAt', 'deletedAt'],
  },
})
export class Product extends BaseEntity<typeof ProductSchema> {}

3. Prefer soft delete; reserve hard delete for cleanup

typescript
// Normal operation — soft delete
await repository.deleteById({ id });

// Cleanup job — hard delete old soft-deleted records
await repository.deleteAll({
  where: { deletedAt: { lt: thirtyDaysAgo } },
  options: { shouldHardDelete: true, shouldSkipDefaultFilter: true, force: true },
});

4. Use isStrict for controller-level lookups

typescript
// Throws 404 automatically — no manual null checking needed
const product = await repository.findById({
  id: params.id,
  options: { isStrict: true },
});

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.