SoftDeletableRepository
A repository that overrides delete operations to set a deletedAt timestamp instead of physically removing records. Extends DefaultCRUDRepository with restore capabilities.
File: packages/core/src/base/repositories/core/soft-deletable.ts
Setup
1. Define Model with Soft Delete
Use the generateTzColumnDefs enricher with the deleted option enabled to add a deletedAt column:
import { pgTable, text } from 'drizzle-orm/pg-core';
import {
BaseEntity,
model,
generateIdColumnDefs,
generateTzColumnDefs,
} from '@venizia/ignis';
@model({
type: 'entity',
settings: {
hiddenProperties: ['deletedAt'],
defaultFilter: { where: { deletedAt: null } },
},
})
export class Category extends BaseEntity<typeof Category.schema> {
static override schema = pgTable('Category', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateTzColumnDefs({
deleted: { enable: true, columnName: 'deleted_at', withTimezone: true },
}),
name: text('name').notNull(),
});
}IMPORTANT
- The model must have a
deletedAtcolumn (Date | null). TheSoftDeletableRepositoryrequiresTSoftDeletableTableSchemawhich enforces{ deletedAt: AnyPgColumn<{ data: Date | null }> }. - Set
defaultFilter: { where: { deletedAt: null } }in@modelsettings so soft-deleted records are excluded by default. - Optionally add
deletedAttohiddenPropertiesto hide it from API responses. - Use
generateTzColumnDefswithdeleted: { enable: true, ... }to add the column, or define it manually withtimestamp('deleted_at', { mode: 'date', withTimezone: true }).
2. Create Repository
import { repository, SoftDeletableRepository } from '@venizia/ignis';
import { Category } from '@/models/category.model';
import { PostgresDataSource } from '@/datasources/postgres.datasource';
@repository({ model: Category, dataSource: PostgresDataSource })
export class CategoryRepository extends SoftDeletableRepository<typeof Category.schema> {}Delete Operations
All delete methods set deletedAt = new Date() instead of removing the row. They internally call the corresponding update method (updateById or updateAll).
deleteById
// Soft delete - sets deletedAt timestamp
const result = await repo.deleteById({ id: '123' });
// { count: 1, data: { id: '123', name: 'Electronics', deletedAt: '2026-03-06T...' } }
// Without returning data
const result = await repo.deleteById({
id: '123',
options: { shouldReturn: false },
});
// Hard delete - physically removes the row
const result = await repo.deleteById({
id: '123',
options: { shouldHardDelete: true },
});deleteAll
// Soft delete all matching records
const result = await repo.deleteAll({
where: { status: 'archived' },
options: { force: true },
});
// Hard delete all matching records
const result = await repo.deleteAll({
where: { status: 'archived' },
options: { shouldHardDelete: true, force: true },
});deleteBy
// Soft delete by where condition (alias for deleteAll)
const result = await repo.deleteBy({
where: { name: 'Obsolete' },
});
// Hard delete by where condition
const result = await repo.deleteBy({
where: { name: 'Obsolete' },
options: { shouldHardDelete: true },
});Restore Operations
Restore methods set deletedAt = null and automatically use shouldSkipDefaultFilter: true to find soft-deleted records.
restoreById
const result = await repo.restoreById({ id: '123' });
// { count: 1, data: { id: '123', name: 'Electronics', deletedAt: null } }
// Without returning data
const result = await repo.restoreById({
id: '123',
options: { shouldReturn: false },
});restoreAll
// Restore all soft-deleted records (requires force for empty where)
const result = await repo.restoreAll({
where: {},
options: { force: true },
});
// Restore matching records
const result = await repo.restoreAll({
where: { name: 'Electronics' },
});restoreBy
// Alias for restoreAll
const result = await repo.restoreBy({
where: { status: 'archived' },
});Read Operations
findById with isStrict
SoftDeletableRepository overrides findById to support an isStrict option that throws a 404 Not Found error when the record doesn't exist:
// Returns null if not found (default)
const category = await repo.findById({ id: '123' });
// Throws 404 if not found
const category = await repo.findById({
id: '123',
options: { isStrict: true },
});
// Throws: [CategoryRepository][findById] Entity with id 123 not found (HTTP 404)All other read operations (find, findOne, count, existsWith) work as normal. The default filter ({ deletedAt: null }) automatically excludes soft-deleted records. Use shouldSkipDefaultFilter: true to include them.
Options Reference
Delete Options
| Option | Type | Default | Description |
|---|---|---|---|
shouldHardDelete | boolean | false | Bypass soft delete and physically remove the row |
shouldReturn | boolean | true | Return the updated/deleted record |
force | boolean | false | Allow empty where condition (deleteAll/deleteBy) |
transaction | ITransaction | - | Transaction context |
log | { use: boolean; level?: TLogLevel } | - | Enable operation logging |
shouldSkipDefaultFilter | boolean | false | Bypass the default filter |
Restore Options
| Option | Type | Default | Description |
|---|---|---|---|
shouldReturn | boolean | true | Return the restored record |
force | boolean | false | Allow empty where condition (restoreAll) |
transaction | ITransaction | - | Transaction context |
NOTE
Restore operations automatically set shouldSkipDefaultFilter: true internally so they can find soft-deleted records that the default filter would normally hide. You do not need to set this yourself.
How It Works
| Operation | SQL Behavior |
|---|---|
deleteById | UPDATE SET deletedAt = NOW() WHERE id = ? (via updateById) |
deleteAll / deleteBy | UPDATE SET deletedAt = NOW() WHERE ... (via updateAll) |
restoreById | UPDATE SET deletedAt = NULL WHERE id = ? (via updateById with shouldSkipDefaultFilter: true) |
restoreAll / restoreBy | UPDATE SET deletedAt = NULL WHERE ... (via updateAll with shouldSkipDefaultFilter: true) |
find / findOne / count | Default filter automatically adds WHERE deletedAt IS NULL |
deleteById({ shouldHardDelete: true }) | DELETE FROM ... WHERE id = ? (delegates to parent PersistableRepository) |
TIP
The shouldHardDelete option bypasses soft delete entirely and delegates to the parent DefaultCRUDRepository's delete implementation, which performs a real SQL DELETE.
With Transactions
const tx = await this.dataSource.beginTransaction();
try {
await this.categoryRepo.deleteById({ id: '123', options: { transaction: tx } });
await this.auditRepo.create({
data: { action: 'soft_delete', entityId: '123' },
options: { transaction: tx },
});
await tx.commit();
} catch {
await tx.rollback();
}Type Constraint
SoftDeletableRepository enforces that the schema includes a deletedAt column at the type level:
export type TSoftDeletableTableSchema = TTableSchemaWithId & {
deletedAt: AnyPgColumn<{ data: Date | null }>;
};
export class SoftDeletableRepository<
EntitySchema extends TSoftDeletableTableSchema = TSoftDeletableTableSchema,
// ...
> extends DefaultCRUDRepository<EntitySchema, ...> { }If your schema does not have a deletedAt column, you will get a TypeScript compilation error when extending SoftDeletableRepository.
Class Hierarchy
AbstractRepository
-> ReadableRepository
-> PersistableRepository
-> DefaultCRUDRepository
-> SoftDeletableRepository <-- you are hereQuick Reference
| Want to... | Code |
|---|---|
| Soft delete by ID | repo.deleteById({ id }) |
| Hard delete by ID | repo.deleteById({ id, options: { shouldHardDelete: true } }) |
| Soft delete by condition | repo.deleteAll({ where, options: { force: true } }) |
| Restore by ID | repo.restoreById({ id }) |
| Restore by condition | repo.restoreAll({ where }) |
| Find including deleted | repo.find({ filter, options: { shouldSkipDefaultFilter: true } }) |
| Strict findById (404) | repo.findById({ id, options: { isStrict: true } }) |
Next Steps
- Advanced Features - Transactions, hidden properties
- Repository Mixins - Default filter and fields visibility
- Repository Overview - Repository basics
See Also
- Related Concepts:
- Repositories Overview - Core repository operations
- Default Filter - Automatic filtering
- Models - Entity definitions with enrichers