Skip to content

Default Filter v0.0.5+ ​

A model's settings.defaultFilter merges into every find/findOne/findById/count/updateById/updateAll/deleteById/deleteAll call for that model. It's 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.

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;
}

Options ​

OptionTypeDefaultMeaning
settings.defaultFilterTFilternoneFilter merged into every query for the model - where, limit, offset, order, fields, include are all valid inside it.
settings.defaultLimitnumber (positive integer)DEFAULT_LIMIT (10)Per-model row cap. Independent of defaultFilter - see Fields, Order & Pagination -> Default limit resolution.
options.shouldSkipDefaultFilterbooleanfalseSkips the defaultFilter merge for one call. Does not drop defaultLimit.
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. Take a default scope - a createdAt floor, a tenant inq. A caller filter can narrow it, but never widen or drop it.
  • 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. Every repository verb honors it - 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 does not lift a row scope

shouldSkipDefaultFilter clears settings.defaultFilter only. A model's settings.scopeFilter is AND-composed on top and stays applied - a tenant or ownership boundary must not be liftable by an options flag. See @model options.

updateById and deleteById merge the default filter into their { id } condition the same way updateAll/deleteAll merge it into their where. The bypass applies to all four identically:

typescript
// Also merges the default filter into { id: postId } - skip to update a soft-deleted row
await postRepository.updateById({
  id: postId,
  data: { title: 'Restored' },
  options: { shouldSkipDefaultFilter: true },
});

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

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.

Configuring a default filter ​

Any TFilter property is valid inside defaultFilter - where, limit, offset, order, fields, include (see Filter System Overview). Two shapes cover most cases.

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 resolution). Unlike defaultFilter, it 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 (packages/connectors/src/relational/core/repositories/core/base.ts) implements the default-filter behavior directly - no mixin is composed onto it. PostgresBaseRepository is a subclass of it, not an alias, and inherits every method below:

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

getDefaultFilter() reads this.modelSettings?.defaultFilter. modelSettings is a protected getter on AbstractRepository (packages/kernel/src/base/repositories/core/abstract.ts), resolved from MetadataRegistry by the entity's constructor - not by name string - on first access, then memoized.

Read verbs (find/findOne/findById/count) call applyDefaultFilter() directly. Write verbs (updateById/updateAll/deleteById/deleteAll) route through the shared _update/_delete helpers instead. Those helpers call it against { where: opts.where } (or { id } for the ById forms) before building the SQL condition.

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(). Reach it through the datasource's query dialect - FilterBuilder is abstract, so you never construct it directly:

typescript
const queryDialect = dataSource.getQueryDialect();

queryDialect.mergeFilter({
  defaultFilter: { where: { isDeleted: false }, limit: 100 },
  userFilter: { where: { status: 'active' }, limit: 10 },
});
// { where: { isDeleted: false, status: 'active' }, limit: 10 }

See also ​

Files: