Default Filter v0.0.5+
A model's settings.defaultFilter merges into every find/findOne/findById/count/updateAll/deleteAll call for that model - the standard way to implement soft delete, multi-tenancy, active-record scoping, and query-limit protection without repeating a where clause at every call site.
Quick start
import { model, BaseEntity } from '@venizia/ignis';
import { userTable } from '@/schemas';
@model({
type: 'entity',
settings: {
defaultFilter: { where: { isDeleted: false }, limit: 100 },
},
})
export class User extends BaseEntity<typeof User.schema> {
static override schema = userTable;
}import { userRepository } from '@/repositories';
await userRepository.find({ filter: { where: { status: 'active' } } });
// WHERE "isDeleted" = false AND "status" = 'active' LIMIT 100Merge semantics
applyDefaultFilter() merges the model's defaultFilter with the caller's filter via FilterBuilder.mergeFilter().
wherenarrows per-key. See the narrowing law below.- Everything else is user-wins-if-provided. A caller value replaces the default, but a caller value of
undefinednever does - a filter built by spreading an optional object can't silently blow away a tenant scope or a limit.
| Property | Merge strategy |
|---|---|
where | Per-key narrowing (below) |
limit, offset/skip, order, fields, include | Caller replaces default, if the caller's value is defined |
The where narrowing law
Keys present on only one side pass through untouched. When the same key appears on both sides, the outcome depends on shape:
| Default | Caller | Result |
|---|---|---|
| scalar | scalar | Caller wins - the one true override (isDeleted: false -> isDeleted: true opts an admin out of soft-delete) |
| operator object | operator object | AND-composed into an and: [...] group - both conditions apply |
| scalar | operator object | AND-composed |
| operator object | scalar | AND-composed |
andcollisions concatenate. Both conjunct lists merge into one.orcollisions cannot concatenate - that would union, not narrow - so each side'sorgroup becomes its own conjunct instead.- Non-scalar collisions always AND-compose. A default scope - a
createdAtfloor, a tenantinq- can be narrowed by a caller filter but never widened or dropped. - Only scalar-over-scalar is a true override. Every other collision shape composes rather than replaces.
// Default: a floor on createdAt. Caller: an upper bound on the same key.
const defaultFilter = { where: { createdAt: { gte: '2024-01-01' } } };
const userFilter = { where: { createdAt: { lte: '2024-12-31' } } };
// Both operator objects on the same key -> AND-composed, so the floor survives:
// { where: { and: [{ createdAt: { gte: '2024-01-01' } }, { createdAt: { lte: '2024-12-31' } }] } }Non-colliding keys still combine with an implicit AND, exactly like two where objects merged by hand:
// Default: { where: { isDeleted: false, tenantId: 'tenant-123' } }
// Caller: { where: { or: [{ status: 'active' }, { priority: 'high' }] } }
// Result: WHERE "isDeleted" = false AND "tenantId" = 'tenant-123' AND ("status" = 'active' OR "priority" = 'high')Bypassing the default filter
Pass shouldSkipDefaultFilter: true in options to skip the merge entirely. It is honored by every repository verb - find, findOne, findById, count, updateById, updateAll, deleteById, deleteAll:
// Normal - default filter applies
await repository.find({ filter: { where: { role: 'admin' } } });
// WHERE "isDeleted" = false AND "role" = 'admin'
// Admin/maintenance path - bypassed
await repository.find({
filter: { where: { role: 'admin' } },
options: { shouldSkipDefaultFilter: true },
});
// WHERE "role" = 'admin' (includes soft-deleted rows)It composes with a transaction the same way any other option does:
const tx = await repository.beginTransaction();
try {
await repository.updateAll({
where: { status: 'archived' },
data: { isDeleted: true },
options: { transaction: tx, shouldSkipDefaultFilter: true },
});
await tx.commit();
} catch (e) {
await tx.rollback();
throw e;
}updateAll/deleteAll additionally require force: true when the resulting where is empty - see Advanced Repository Features -> Empty where protection.
| Scenario | Why bypass |
|---|---|
| Admin dashboard | View records a default scope would otherwise hide |
| Data recovery | Restore soft-deleted rows |
| Cross-tenant analytics | Count/aggregate across every tenant |
| Data migration | Update rows regardless of status |
Configuring a default filter
Any TFilter property is valid inside defaultFilter - where, limit, offset, order, fields, include (see Filter System Overview). The two recurring shapes:
Soft delete or multi-tenant scoping - a where clause that every query must carry:
@model({
type: 'entity',
settings: { defaultFilter: { where: { deletedAt: null } } },
})
export class Post extends BaseEntity<typeof Post.schema> {}
await postRepository.find({ filter: {} });
// WHERE "deletedAt" IS NULL
await postRepository.updateById({
id: postId,
data: { deletedAt: null },
options: { shouldSkipDefaultFilter: true }, // restore
});Query-limit protection - prefer the dedicated settings.defaultLimit over a limit inside defaultFilter. It resolves independently (query.limit ?? defaultLimit ?? 10, see Fields, Order & Pagination -> Default Limit) and, unlike defaultFilter, is not dropped by shouldSkipDefaultFilter:
@model({
type: 'entity',
settings: { defaultLimit: 1000 },
})
export class LogEntry extends BaseEntity<typeof LogEntry.schema> {}
await logEntryRepository.find({ filter: {} }); // LIMIT 1000
await logEntryRepository.find({ filter: { limit: 50 } }); // LIMIT 50@model validates defaultLimit at decoration time - it must be a positive integer or the class throws on load.
Relation include default filters
include also applies the related model's defaultFilter, and it can be bypassed or scoped per relation:
await repository.find({
filter: {
include: [
{ relation: 'posts' }, // related model's default filter applies
{ relation: 'comments', shouldSkipDefaultFilter: true }, // skipped for this relation only
{ relation: 'tags', scope: { limit: 10, order: ['name ASC'] } }, // scope merges with the default filter
],
},
});How it works
+------------------+ +--------------------------+ +------------------+
| Model Settings | --> | RelationalBaseRepository | --> | Repository Method |
| defaultFilter | | applyDefaultFilter() | | find/count/etc |
+------------------+ +--------------------------+ +------------------+
|
v
+------------------+
| FilterBuilder |
| mergeFilter() |
+------------------+RelationalBaseRepository (compatibility alias PostgresBaseRepository, packages/core/src/connectors/postgres/repositories/core/base.ts) implements the default-filter behavior directly - no mixin is composed onto it:
hasDefaultFilter(): boolean
getDefaultFilter(): TFilter | undefined
getDefaultLimit(): number | undefined
applyDefaultFilter(opts: { userFilter?: TFilter; shouldSkipDefaultFilter?: boolean }): TFiltergetDefaultFilter() reads this.modelSettings?.defaultFilter, where modelSettings is a protected getter on AbstractRepository (src/base/repositories/core/abstract.ts) resolved from MetadataRegistry by the entity's constructor (not by name string) on first access, then memoized.
NOTE
An older DefaultFilterMixin implemented this same behavior via mixin composition. It is no longer composed onto any repository class - see Repository Mixins (Removed) for history.
The merge itself is FilterBuilder.mergeFilter():
const filterBuilder = new FilterBuilder();
filterBuilder.mergeFilter({
defaultFilter: { where: { isDeleted: false }, limit: 100 },
userFilter: { where: { status: 'active' }, limit: 10 },
});
// { where: { isDeleted: false, status: 'active' }, limit: 10 }IExtraOptions
shouldSkipDefaultFilter lives on the same options interface every repository verb accepts:
interface IExtraOptions extends IWithTransaction {
shouldSkipDefaultFilter?: boolean;
log?: TRepositoryLogOptions;
lock?: TLockOptions;
}
interface IWithTransaction {
transaction?: ITransaction;
}log and lock are documented in Advanced Repository Features - log only takes effect on write verbs (create/updateById/updateAll/deleteById/deleteAll), not on reads.
Quick reference
| Want to... | Code |
|---|---|
| Configure a default filter | @model({ settings: { defaultFilter: { ... } } }) |
| Bypass the default filter | options: { shouldSkipDefaultFilter: true } |
| Bypass for one relation | include: [{ relation: 'x', shouldSkipDefaultFilter: true }] |
| Combine with a transaction | options: { transaction: tx, shouldSkipDefaultFilter: true } |
| Check if a model has a default | repository.hasDefaultFilter() |
| Read the raw default filter | repository.getDefaultFilter() |
| Read the raw default limit | repository.getDefaultLimit() |
See also
- Filter System Overview - the
filtershape and every operator family - Fields, Order & Pagination -
defaultLimitresolution in full - Advanced Repository Features - transactions,
log/lockoptions, empty-where protection - Repository Mixins (Removed) - history of the removed
DefaultFilterMixin
Files:
packages/core/src/connectors/postgres/repositories/dialect/filter.ts-FilterBuilder.mergeFilter()/mergeWhere(), the narrowing mergepackages/core/src/connectors/postgres/repositories/core/base.ts-RelationalBaseRepository,applyDefaultFilter/getDefaultFilter/getDefaultLimitpackages/core/src/base/metadata/persistents.ts-@modeldecorator,defaultLimitvalidationpackages/core/src/base/repositories/common/types.ts-IExtraOptions,IWithTransaction