Model Scopes
Model scopes provide a way to define reusable, composable query filters at the model level. They help keep your code DRY and make common query patterns more maintainable.
Defining Scopes
Scopes are defined as a static scopes property on your model class. Each scope can be either an options object or a function that returns options.
Basic Scopes
class Users {
static table = 'users';
static fields = {
id: 'primary',
name: 'string',
email: 'string',
active: { type: 'boolean', default: true },
created_at: { type: 'datetime', default: () => new Date() },
};
static scopes = {
// Simple scope with where clause
active: {
where: { active: true },
},
// Scope with ordering and limit
recent: {
order: [['created_at', 'DESC']],
limit: 10,
},
// Scope with caching
popular: {
where: { followers: { gte: 1000 } },
cache: 300, // Cache for 5 minutes
},
};
}
Parameterized Scopes
Scopes can be functions that accept parameters:
class Posts {
static table = 'posts';
static fields = {
id: 'primary',
title: 'string',
published_at: { type: 'datetime' },
views: { type: 'number', default: 0 },
};
static scopes = {
// Function-based scope with parameters
recentDays: (qb, days = 7) => {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
return {
where: { published_at: { gte: cutoff } },
};
},
minViews: (qb, minimum = 100) => ({
where: { views: { gte: minimum } },
}),
};
}
Using Scopes
Applying Single Scopes
// Apply a single scope
const activeUsers = await repo.Users.scope('active');
// Apply parameterized scope (single value - recommended)
const recentPosts = await repo.Posts.scope({ recentDays: 30 });
// Array syntax also supported for backward compatibility
const recentPosts = await repo.Posts.scope({ recentDays: [30] });
Composing Multiple Scopes
Multiple scopes can be combined, and they are merged with AND logic for where clauses:
// Combine multiple scopes
const popularActivePosts = await repo.Posts
.scope('active', { minViews: 1000 });
// Array syntax also works
const popularActivePosts = await repo.Posts
.scope('active', { minViews: [1000] });
// Where clauses are AND-ed together
// Result: active = true AND views >= 1000
Default Scopes
A default scope is automatically applied to all queries unless explicitly bypassed:
class Tasks {
static table = 'tasks';
static fields = {
id: 'primary',
title: 'string',
deleted_at: { type: 'datetime', default: null },
archived: { type: 'boolean', default: false },
};
// Default scope applies to all queries
static defaultScope = {
where: {
deleted_at: null,
archived: false
},
};
static scopes = {
withArchived: {
where: { deleted_at: null },
// Only filters deleted, includes archived
},
};
}
// Queries automatically apply defaultScope
const tasks = await repo.Tasks.query(); // Only non-deleted, non-archived
// Named scopes are combined with defaultScope
const withArchived = await repo.Tasks.scope('withArchived');
// Bypass defaultScope with unscoped()
const allTasks = await repo.Tasks.unscoped();
Scope Options
where
Filter criteria using the same format as .where():
{
where: {
active: true,
status: { in: ['pending', 'approved'] },
created_at: { gte: someDate },
}
}
Supports all operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, null, between, etc.
include
Mark relations for eager loading:
{
include: [
{ relation: 'profile' },
{ relation: 'posts', limit: 5 }
]
}
Note: Current implementation marks relations but doesn't pre-load them in bulk. Relations are still loaded via their proxies.
cache
Enable caching for the scope:
{
cache: true, // Default 300 seconds (5 minutes)
}
// Or specify TTL in seconds
{
cache: 600, // 10 minutes
}
order
Specify result ordering:
{
order: [
['created_at', 'DESC'],
['priority', 'ASC']
]
}
limit and offset
Pagination options:
{
limit: 10,
offset: 20
}
attributes
Select specific fields (currently not implemented, reserved for future use):
{
attributes: ['id', 'name', 'email']
}
Scope Merging Behavior
When multiple scopes are combined:
- where clauses: Merged with AND logic
- include arrays: Concatenated and deduplicated by relation name
- order, limit, offset, cache: Last scope wins
Example:
class Products {
static scopes = {
active: {
where: { active: true },
order: [['name', 'ASC']],
limit: 100,
},
featured: {
where: { featured: true },
order: [['priority', 'DESC']],
limit: 10,
},
};
}
// Combined result:
// where: { active: true AND featured: true }
// order: [['priority', 'DESC']] // Last wins
// limit: 10 // Last wins
await repo.Products.scope('active', 'featured');
Best Practices
1. Keep Scopes Focused
Each scope should represent a single, clear filtering concept:
// Good
static scopes = {
active: { where: { active: true } },
published: { where: { published: true } },
recent: { order: [['created_at', 'DESC']], limit: 10 },
}
// Less ideal - scope does too much
static scopes = {
activePublishedRecent: {
where: { active: true, published: true },
order: [['created_at', 'DESC']],
limit: 10,
},
}
2. Use Default Scopes for Soft Deletes
class Documents {
static fields = {
deleted_at: { type: 'datetime', default: null },
};
static defaultScope = {
where: { deleted_at: null },
};
// Access soft-deleted records when needed
static async findWithDeleted() {
return this.unscoped().query();
}
}
3. Combine Scopes for Complex Queries
// Instead of one complex scope, combine simple ones
const results = await repo.Users
.scope('active', { recentDays: [7] }, 'emailVerified')
.where({ role: 'admin' });
4. Document Parameterized Scopes
static scopes = {
/**
* Find records created within the specified number of days
* @param {number} days - Number of days to look back (default: 7)
*/
recentDays: (qb, days = 7) => ({
where: {
created_at: { gte: Date.now() - days * 24 * 60 * 60 * 1000 }
},
}),
}
TypeScript Support
Scopes work seamlessly with TypeScript:
class Users {
static table = 'users';
static fields = {
id: 'primary' as const,
name: 'string' as const,
active: { type: 'boolean', default: true },
};
static scopes = {
active: {
where: { active: true },
},
};
}
Migration from Sequelize
If you're migrating from Sequelize, scopes work similarly:
// Sequelize
User.addScope('active', {
where: { active: true }
});
// NormalJS
class User {
static scopes = {
active: {
where: { active: true }
}
}
}
// Usage is similar
const activeUsers = await User.scope('active').findAll(); // Sequelize
const activeUsers = await repo.Users.scope('active'); // NormalJS
Limitations
Current limitations of the scope implementation:
-
Bulk Eager Loading: The
includeoption marks relations for loading but doesn't pre-load them in bulk. Relations are loaded via their proxies when accessed. -
Nested Includes: Deep nested includes are not yet fully supported.
-
Attributes Selection: The
attributesoption is reserved but not yet implemented. -
Tag-based Cache Invalidation: Automatic cache invalidation on writes is not yet implemented.
These features may be added in future releases.
Examples
Multi-tenancy
class Documents {
static fields = {
tenant_id: { type: 'number', required: true },
user_id: { type: 'number', required: true },
};
static defaultScope = {
where: { tenant_id: global.currentTenantId },
};
static scopes = {
forUser: (qb, userId) => ({
where: { user_id: userId },
}),
};
}
// Automatically filters by tenant
const docs = await repo.Documents.query();
// Filter by user within tenant
const userDocs = await repo.Documents.scope({ forUser: [123] });
Privacy Filters
class Posts {
static fields = {
visibility: { type: 'string', default: 'public' },
};
static defaultScope = {
where: { visibility: 'public' },
};
static scopes = {
all: {}, // Empty scope to override default
};
}
// Public posts only (default)
const publicPosts = await repo.Posts.query();
// All posts (bypass default)
const allPosts = await repo.Posts.unscoped();