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)

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 (compatibility alias PostgresBaseRepository, packages/connectors/src/relational/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. 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.

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: