where filter, anorder, an attributes projection, a limit, and so on — and attach them to afindAll / findOne /count unless you explicitly opt out.Model.scope('name').[!NOTE] scope()andunscoped()return a scoped copy of the model and leave the
model itself alone, so a scoped call never changes what a later plain call
sends. Recorded on SQLite:Earlier releases changed the base model on define()models, so oneUser.unscoped()dropped the default scope for every later query. Even
now, a default scope is a convenience, not a security boundary: any call
site can opt out withunscoped(). Use row-level security
or abeforeFindhook for tenancy.
import { Prorm, DataTypes } from 'ts-prorm-orm';
const prorm = new Prorm({ dialect: 'sqlite', storage: ':memory:' });
await prorm.connect();
const User = prorm.define(
'User',
{
name: { type: DataTypes.STRING(100) },
status: { type: DataTypes.STRING(20) },
role: { type: DataTypes.STRING(20) },
createdAt: { type: DataTypes.DATE() },
},
{
defaultScope: {
where: { status: 'active' },
order: [['createdAt', 'DESC']],
},
scopes: {
admin: { where: { role: 'admin' } },
active: { where: { status: 'active' } },
inactive: { where: { status: 'inactive' } },
nameOnly: { attributes: ['id', 'name'] },
},
},
);FindOptions. The supportedwhere, order, attributes, include, limit, and offset.defaultScope is folded into every query on the model. No// Automatically filtered to status = 'active' and ordered by createdAt DESC
const activeUsers = await User.findAll();where keys are merged with the call's where. When the same key appearsorder, limit, and attributes apply only if you did not set them on// Your explicit ordering overrides the default scope's order,
// but the default status = 'active' filter still applies.
const byName = await User.findAll({ order: [['name', 'ASC']] });
// Your where key replaces the default's for that key.
const banned = await User.findAll({ where: { status: 'banned' } });
// count() honours the default scope too.
const n = await User.count();scopes and pulled in with Model.scope(name),const admins = await User.scope('admin').findAll();
const count = await User.scope('admin').count();where is no longer added. That is why theinactive scope can return inactive rows even though the default scope filtersstatus = 'active':// Returns inactive users — the default `status = 'active'` filter is dropped
const inactive = await User.scope('inactive').findAll();User.getScope('defaultScope'); // the default scope definition, evaluatedwhere clauses from several named scopeslimit and order do not combine: the first one set wins, and the call's ownwhere clauses// role = 'admin' AND status = 'active'
const activeAdmins = await User.scope(['admin', 'active']).findAll();scope() returns a model you can scope again, so the calls also chainconst activeAdmins = await User.scope('admin').scope('active').findAll();where options in a combined scope, the first value set wins: once anorder, limit, or attributes has been contributed by a scope (or by yourwhereconst results = await User.scope('admin').findAll({
where: { status: 'active' },
});where keys are combined (ANDed together), so the query aboverole = 'admin' AND status = 'active'. When the scope and your call-sitewhere set the same key, the named scope's value takes precedence:// role: 'user' is silently replaced by the scope's role: 'admin'
await User.scope('admin').findAll({ where: { role: 'user', status: 'active' } });[!WARNING]
This is the opposite of the default scope, where the call site wins. If a
caller must be able to narrow a value, leave that key out of the named scope
and pass it as a scope argument instead.
import { DataTypes, Op } from 'ts-prorm-orm';
const User = prorm.define(
'User',
{
name: { type: DataTypes.STRING(100) },
status: { type: DataTypes.STRING(20) },
role: { type: DataTypes.STRING(20) },
createdAt: { type: DataTypes.DATE() },
},
{
scopes: {
nameOnly: { attributes: ['id', 'name'] },
byRole: (role: string) => ({ where: { role } }),
recent: (days: number) => ({
where: { createdAt: { [Op.gte]: new Date(Date.now() - days * 86_400_000) } },
order: [['createdAt', 'DESC']],
limit: 50,
}),
},
},
);
// byRole('editor'), then recent(7), then nameOnly
const editors = await User.scope(['byRole', 'editor', 'recent', 7, 'nameOnly']).findAll();limit in recent is where the engines differ:Date in the process's local time zone2026-09-18 20:00:00.000 above, from a UTC-4 machine), PostgreSQL and SQLitetimezone on the connection when that matters.[!TIP]
Because a string argument is only treated as a new scope when it names a
scope,['byRole', 'admin']passes'admin'tobyRoleeven though a scope
calledadminexists… unless it does — then'admin'starts theadmin
scope andbyRolegets no argument. Pick argument values and scope names that
cannot collide, or apply the scopes onescope()call at a time.
addScope() — registering scopes after definitionaddScope(name, definition)define.User.addScope('users', { where: { role: 'user' } });
const regularUsers = await User.scope('users').findAll();findAll resolves the scopes that existunscoped()unscoped() returns a model view with no scopes applied — not the default// Every user, regardless of status (and see the caution at the top of the page)
const everyone = await User.unscoped().findAll();
// Still no default scope, but with your own filter
const archived = await User.unscoped().findAll({
where: { status: 'archived' },
});User.hasScope('admin'); // true
User.getScope('admin'); // { where: { role: 'admin' } }
User.getScopes(); // { admin: {...}, active: {...}, ... }
User.allScopes(); // alias for getScopes()
User.removeScope('inactive'); // true; deletes the named scope
User.clearScopes(); // remove every named scopehasScope(name) reports whether a named scope is registered.getScope(name) returns a single scope definition. A getScope('defaultScope')getScopes() (and its alias allScopes()) returns every named scope as aremoveScope(name) deletes one named scope and returns true if it existed,false otherwise.clearScopes() removes all named scopes at once. The default scope is leftFindOptions:where combines with your call-site whereorder, limit, and attributes fill inwhere is merged into the query in order (laterorder, limit, and attributes keep theunscoped() skips both steps — nothing is merged.beforeFind hook gives the same "every query isimport { AsyncLocalStorage } from 'node:async_hooks';
export const tenantContext = new AsyncLocalStorage<{ tenantId: number }>();
export function enforceTenant(Model: { hook: (name: string, fn: (opts: any) => void) => unknown }) {
Model.hook('beforeFind', (findOptions) => {
const ctx = tenantContext.getStore();
if (!ctx) throw new Error('query outside a tenant context');
findOptions.where = { ...(findOptions.where ?? {}), tenantId: ctx.tenantId };
});
}enforceTenant(Invoice);
// in the request handler
await tenantContext.run({ tenantId: 42 }, () => Invoice.findAll({ where: { status: 'open' } }));