Skip to content

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

typescript
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;
}
typescript
import { userRepository } from '@/repositories';

await userRepository.find({ filter: { where: { status: 'active' } } });
// WHERE "isDeleted" = false AND "status" = 'active' LIMIT 100

Merge semantics

applyDefaultFilter() merges the model's defaultFilter with the caller's filter via FilterBuilder.mergeFilter().

  • where narrows 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 undefined never does - a filter built by spreading an optional object can't silently blow away a tenant scope or a limit.
PropertyMerge strategy
wherePer-key narrowing (below)
limit, offset/skip, order, fields, includeCaller 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:

DefaultCallerResult
scalarscalarCaller wins - the one true override (isDeleted: false -> isDeleted: true opts an admin out of soft-delete)
operator objectoperator objectAND-composed into an and: [...] group - both conditions apply
scalaroperator objectAND-composed
operator objectscalarAND-composed
  • and collisions concatenate. Both conjunct lists merge into one.
  • or collisions cannot concatenate - that would union, not narrow - so each side's or group becomes its own conjunct instead.
  • Non-scalar collisions always AND-compose. A default scope - a createdAt floor, a tenant inq - 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.
typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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.

ScenarioWhy bypass
Admin dashboardView records a default scope would otherwise hide
Data recoveryRestore soft-deleted rows
Cross-tenant analyticsCount/aggregate across every tenant
Data migrationUpdate 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:

typescript
@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:

typescript
@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:

typescript
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:

typescript
hasDefaultFilter(): boolean
getDefaultFilter(): TFilter | undefined
getDefaultLimit(): number | undefined
applyDefaultFilter(opts: { userFilter?: TFilter; shouldSkipDefaultFilter?: boolean }): TFilter

getDefaultFilter() 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():

typescript
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:

typescript
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 filteroptions: { shouldSkipDefaultFilter: true }
Bypass for one relationinclude: [{ relation: 'x', shouldSkipDefaultFilter: true }]
Combine with a transactionoptions: { transaction: tx, shouldSkipDefaultFilter: true }
Check if a model has a defaultrepository.hasDefaultFilter()
Read the raw default filterrepository.getDefaultFilter()
Read the raw default limitrepository.getDefaultLimit()

See also

Files: