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
SoftDeletableRepositoryand its parentDefaultCRUDRepositoryclass. The API and patterns documented below remain accurate.
Class Definition
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
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
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
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.
// 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:
// 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.
// 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:
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.
// 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:
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.
const result = await repository.deleteBy({
where: { status: 'inactive', merchantId: 'merchant-123' },
});Method signatures:
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.
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:
restoreById<R = DataObject>(opts: {
id: IdType;
options?: ExtraOptions & { shouldReturn?: boolean };
}): Promise<TCount & { data: TNullable<R> }>;restoreAll
Restores all soft-deleted records matching a filter.
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:
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.
const result = await repository.restoreBy({
where: { merchantId: 'merchant-123', status: 'active' },
});Method signature:
restoreBy<R = DataObject>(opts: {
where: TWhere<DataObject>;
options?: ExtraOptions & { shouldReturn?: boolean; force?: boolean };
}): Promise<TCount & { data: TNullable<Array<R>> }>;API Summary
| Method | Default Behavior | Key Options |
|---|---|---|
findById | Returns null if not found | isStrict: true throws 404 |
deleteById | Sets deletedAt = new Date() | shouldHardDelete: true for permanent removal |
deleteAll | Sets deletedAt = new Date() | shouldHardDelete, force |
deleteBy | Sets deletedAt = new Date() | shouldHardDelete, force |
restoreById | Sets deletedAt = null | shouldReturn |
restoreAll | Sets deletedAt = null | shouldReturn, force |
restoreBy | Delegates to restoreAll | shouldReturn, force |
Usage Examples
Complete Repository Definition
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
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
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:
// 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 NULLBypassing the Default Filter
Use shouldSkipDefaultFilter: true to include soft-deleted records:
// 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
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
// 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
@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
// 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
// Throws 404 automatically — no manual null checking needed
const product = await repository.findById({
id: params.id,
options: { isStrict: true },
});