SoftDeletableRepository
Reference for SoftDeletableRepository - delete methods set a deletedAt timestamp instead of removing the row, with matching restore methods. For the common tasks, start with the Repositories overview.
Files:
packages/connectors/src/relational/core/repositories/core/soft-deletable.ts-SoftDeletableRelationalRepository- delete/restore overrides,isStrictfindById,TDeletedAtColumnpackages/connectors/src/relational/postgres/repositories/core/soft-deletable.ts-SoftDeletableRepository, the Postgres bindingpackages/connectors/src/relational/postgres/models/enrichers/tz.enricher.ts-generateTzColumnDefs- adds thedeletedAtcolumn
Setup
1. Define the model with a deletedAt column
Use the generateTzColumnDefs enricher with the deleted option enabled:
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.SoftDeletableRepositoryrequiresTSoftDeletableTableSchema, which enforces{ deletedAt: AnyColumn<{ data: Date | string | null }> }. The bound is Drizzle's dialect-freeAnyColumn, the exact twin ofAnyPgColumn, so the same column satisfies it on SQLite too. - 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. Extend SoftDeletableRepository
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
- Delete sets a timestamp, not a
DELETE. All delete methods setdeletedAt = new Date()instead of removing the row -deleteByIdcallsupdateByIdinternally,deleteAll/deleteBycallupdateAll. - Bypass with
shouldHardDelete. Passoptions.shouldHardDelete: trueto skip soft delete and delegate to the parent's realDELETE.
deleteById
// Soft delete - sets deletedAt timestamp
const result = await repository.deleteById({ id: '123' });
// { count: 1, data: { id: '123', name: 'Electronics', deletedAt: '2026-03-06T...' } }
// Without returning data
const result = await repository.deleteById({
id: '123',
options: { shouldReturn: false },
});
// Hard delete - physically removes the row
const result = await repository.deleteById({
id: '123',
options: { shouldHardDelete: true },
});deleteAll
where only needs options.force: true when where is empty - a non-empty condition never needs force.
// Soft delete matching records - non-empty where, no force needed
const result = await repository.deleteAll({
where: { status: 'archived' },
});
// Soft delete every row - empty where requires force
const result = await repository.deleteAll({
where: {},
options: { force: true },
});
// Hard delete matching records
const result = await repository.deleteAll({
where: { status: 'archived' },
options: { shouldHardDelete: true },
});deleteBy
Same behavior as deleteAll, but where is a required parameter instead of optional.
// Soft delete by where condition
const result = await repository.deleteBy({
where: { name: 'Obsolete' },
});
// Hard delete by where condition
const result = await repository.deleteBy({
where: { name: 'Obsolete' },
options: { shouldHardDelete: true },
});Restore Operations
- Restore clears the timestamp. Restore methods set
deletedAt = null. - Default filter is bypassed automatically. Restore methods internally pass
shouldSkipDefaultFilter: true, so they can find the soft-deleted records the default filter would otherwise hide. You don't need to set this yourself.
restoreById
const result = await repository.restoreById({ id: '123' });
// { count: 1, data: { id: '123', name: 'Electronics', deletedAt: null } }
// Without returning data
const result = await repository.restoreById({
id: '123',
options: { shouldReturn: false },
});restoreAll
// Restore all soft-deleted records (empty where requires force)
const result = await repository.restoreAll({
where: {},
options: { force: true },
});
// Restore matching records
const result = await repository.restoreAll({
where: { name: 'Electronics' },
});restoreBy
// Calls restoreAll internally
const result = await repository.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 repository.findById({ id: '123' });
// Throws 404 if not found
const category = await repository.findById({
id: '123',
options: { isStrict: true },
});
// Throws: [CategoryRepository][findById] Entity with id 123 not found (HTTP 404)isStrictis the only thing the override adds. Every otherfindByIdoption -retry,transaction,log,shouldSkipDefaultFilter,lock- behaves exactly as onDefaultCRUDRepository. So does thefilterargument.- Everything else is inherited unchanged.
find,findOne,count,existsWithbehave exactly as onDefaultCRUDRepository. - The default filter excludes soft-deleted rows. IGNIS applies
{ deletedAt: null }automatically - passoptions: { shouldSkipDefaultFilter: true }to include them.
You can combine isStrict with read retry. The retry loop runs first, so a strict read waits out replica lag before it throws:
const category = await repository.findById({
id: '123',
options: { retry: { maxAttempts: 4 }, isStrict: true },
});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 an 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 an empty where condition (restoreAll only - restoreById has no force option) |
transaction | ITransaction | - | Transaction context |
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 = ? (with shouldSkipDefaultFilter: true) |
restoreAll / restoreBy | UPDATE SET deletedAt = NULL WHERE ... (with shouldSkipDefaultFilter: true) |
find / findOne / count | Default filter automatically adds WHERE deletedAt IS NULL |
deleteById({ shouldHardDelete: true }) | DELETE FROM ... WHERE id = ? (delegates to the parent repository) |
TIP
shouldHardDelete bypasses soft delete entirely and calls super, which is DefaultRelationalRepository's delete implementation - a real SQL DELETE.
With Transactions
const tx = await this.dataSource.beginTransaction();
try {
await this.categoryRepository.deleteById({ id: '123', options: { transaction: tx } });
await this.auditRepository.create({
data: { action: 'soft_delete', entityId: '123' },
options: { transaction: tx },
});
await tx.commit();
} catch (error) {
await tx.rollback();
throw error;
}Type Constraint
SoftDeletableRepository enforces that the schema includes a deletedAt column at the type level:
export type TDeletedAtColumn = AnyColumn<{ data: Date | string | null }>;
export type TSoftDeletableTableSchema = TTableSchemaWithId & {
deletedAt: TDeletedAtColumn;
};
export class SoftDeletableRelationalRepository<
EntitySchema extends TSoftDeletableTableSchema = TSoftDeletableTableSchema,
// ...
> extends DefaultRelationalRepository<EntitySchema, ...> { }If your schema does not have a deletedAt column, you get a TypeScript compilation error when extending SoftDeletableRepository.
Class Hierarchy
The behavior lives on the engine-neutral SoftDeletableRelationalRepository, the last rung of the relational chain:
AbstractRepository (engine-neutral, @venizia/ignis-kernel)
-> RelationalBaseRepository
-> ReadableRelationalRepository
-> PersistableRelationalRepository
-> DefaultRelationalRepository
-> SoftDeletableRelationalRepositorySoftDeletableRepository is the Postgres binding of that last rung - a subclass, not an alias:
SoftDeletableRelationalRepository
-> SoftDeletableRepository (Postgres) <-- you are here
-> SoftDeletableSqliteRepository (SQLite)It rebinds ExtraOptions to IDatabaseExtraOptions and TDataSource to IPostgresDataSource, and adds nothing else. It does not extend DefaultCRUDRepository, even though the neutral class it extends does extend DefaultRelationalRepository.
Quick Reference
| Want to... | Code |
|---|---|
| Soft delete by ID | repository.deleteById({ id }) |
| Hard delete by ID | repository.deleteById({ id, options: { shouldHardDelete: true } }) |
| Soft delete by condition | repository.deleteAll({ where }) |
| Restore by ID | repository.restoreById({ id }) |
| Restore by condition | repository.restoreAll({ where }) |
| Find including deleted | repository.find({ filter, options: { shouldSkipDefaultFilter: true } }) |
| Strict findById (404) | repository.findById({ id, options: { isStrict: true } }) |
See also
- Repositories overview - CRUD basics, common tasks
- Advanced Features - transactions, hidden properties, performance
- Repository Mixins (Removed) - where default-filter and fields-visibility behavior lives now
- Default Filter - configuring
@modeldefault filters - Models - Full Reference -
@model, entity hierarchy, schema enrichers