Skip to main content

Cookbook

Copy-paste ready recipes for common tasks with NormalJS. All examples are production-ready and follow best practices.

Table of Contents

CRUD Operations

Create a Record

const Users = repo.get('Users');

// ✅ Simple create
const user = await Users.create({
email: 'john@example.com',
name: 'John Doe'
});

// ✅ Create with default values
const user = await Users.create({
email: 'jane@example.com'
// 'active' field uses default: true
// 'created_at' uses default: () => new Date()
});

// ❌ DON'T: Create without transaction for important data
const user = await Users.create({ email }); // Risky if part of larger operation

// ✅ DO: Use transactions for important creates
await repo.transaction(async tx => {
const Users = tx.get('Users');
const user = await Users.create({ email, name });
});

Read Records

const Users = repo.get('Users');

// ✅ Find by ID (uses cache if enabled)
const user = await Users.findById(1);
if (!user) {
throw new Error('User not found');
}

// ✅ Find one by criteria
const user = await Users.where({ email: 'john@example.com' }).first();

// ✅ Find many with filters
const activeUsers = await Users
.where({ active: true })
.orderBy('created_at', 'desc')
.limit(20)
.find();

// ✅ Find with multiple conditions
const users = await Users
.where({ active: true })
.where('created_at', '>', lastWeek)
.find();

// ✅ Check if exists
const exists = await Users.where({ email }).count() > 0;

Update Records

const Users = repo.get('Users');

// ✅ Method 1: Direct modification (optimal for auto-persist)
const user = await Users.findById(1);
user.name = 'Jane Smith';
user.updated_at = new Date();
// Changes persist automatically on next query or transaction flush

// ✅ Method 2: write() with key/value pairs (immediate flush)
const user = await Users.findById(1);
await user.write({
name: 'Jane Smith',
updated_at: new Date()
});

// ✅ Update with validation
const user = await Users.findById(1);
if (user.role !== 'admin') {
await user.write({ email: newEmail });
}

// ❌ DON'T: Bulk update using Knex directly (bypasses hooks!)
await Users.query()
.where('last_login', '<', sixMonthsAgo)
.update({ active: false }); // Bypasses validation, hooks, NormalJS internals!

// ✅ DO: Use transaction with individual updates (hooks run correctly)
await repo.transaction(async tx => {
const Users = tx.get('Users');
const users = await Users.where('last_login', '<', sixMonthsAgo).find();
for (const user of users) {
await user.write({ active: false });
}
});

// ❌ DON'T: Use update() method
await user.update({ email: 'new@example.com' }); // Method doesn't exist!

// ❌ DON'T: Modify then call write() without arguments
user.email = 'new@example.com';
await user.write(); // Anti-pattern!

// ✅ DO: Use one of the two correct methods
user.email = 'new@example.com'; // Direct (auto-persists)
// OR
await user.write({ email: 'new@example.com' }); // Immediate flush

Delete Records

const Users = repo.get('Users');

// ✅ Delete single record
const user = await Users.findById(1);
await user.unlink();

// ✅ Delete with check
const user = await Users.where({ email }).first();
if (user && !user.is_protected) {
await user.unlink();
}

// ❌ DON'T: Bulk delete using Knex directly (bypasses hooks!)
await Users.query()
.where('created_at', '<', oneYearAgo)
.where('active', false)
.delete(); // Bypasses hooks and cascade logic!

// ✅ DO: Use transaction with individual deletes (hooks run correctly)
await repo.transaction(async tx => {
const Users = tx.get('Users');
const users = await Users
.where('created_at', '<', oneYearAgo)
.where('active', false)
.find();
for (const user of users) {
await user.unlink();
}
});

Queries & Filtering

Simple Filters

const Posts = repo.get('Posts');

// ✅ Single condition
const posts = await Posts.where({ published: true }).find();

// ✅ Multiple conditions (AND)
const posts = await Posts
.where({ published: true, featured: true })
.find();

// ✅ Comparison operators
const posts = await Posts
.where('views', '>', 1000)
.where('created_at', '>=', lastMonth)
.find();

// ✅ LIKE queries
const posts = await Posts
.where('title', 'like', '%tutorial%')
.find();

// ✅ IN queries
const posts = await Posts
.whereIn('category_id', [1, 2, 3])
.find();

Complex Filters with JSON Criteria

const Posts = repo.get('Posts');

// ✅ OR conditions
const posts = await Posts.where({
or: [
['published', '=', true],
['author_id', '=', currentUserId]
]
}).find();

// ✅ Nested AND/OR
const posts = await Posts.where({
and: [
['published', '=', true],
{
or: [
['featured', '=', true],
['views', '>', 1000]
]
}
]
}).find();

// ✅ Complex search
const posts = await Posts.where({
and: [
['published', '=', true],
{
or: [
['title', 'like', `%${searchTerm}%`],
['content', 'like', `%${searchTerm}%`]
]
},
['created_at', '>', startDate]
]
}).orderBy('views', 'desc').find();

Sorting and Limiting

const Posts = repo.get('Posts');

// ✅ Order by single field
const posts = await Posts
.where({ published: true })
.orderBy('created_at', 'desc')
.find();

// ✅ Order by multiple fields
const posts = await Posts
.where({ published: true })
.orderBy('featured', 'desc')
.orderBy('views', 'desc')
.find();

// ✅ Pagination
const page = 2;
const perPage = 20;
const posts = await Posts
.where({ published: true })
.limit(perPage)
.offset((page - 1) * perPage)
.find();

Counting

const Posts = repo.get('Posts');

// ✅ Count all
const total = await Posts.count();

// ✅ Count with filters
const publishedCount = await Posts
.where({ published: true })
.count();

// ✅ Check existence
const hasUnpublished = await Posts
.where({ published: false })
.count() > 0;

Relations

Defining Relations

// ✅ Many-to-One (belongs to)
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
author_id: { type: 'many-to-one', model: 'Users' } // Creates FK column
};
}

// ✅ One-to-Many (has many)
class Users {
static _name = 'Users';
static fields = {
id: 'primary',
email: 'string',
posts: { type: 'one-to-many', foreign: 'Posts.author_id' } // Virtual field
};
}

// ✅ Many-to-Many
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
tags: { type: 'many-to-many', model: 'Tags' } // Auto-creates join table
};
}

class Tags {
static _name = 'Tags';
static fields = {
id: 'primary',
posts: { type: 'many-to-many', model: 'Posts' } // Same join table
};
}

Eager Loading Relations

const Posts = repo.get('Posts');
const Users = repo.get('Users');

// ✅ Load single relation
const post = await Posts
.where({ id: 1 })
.include('author')
.first();
console.log(post.author.name);

// ✅ Load multiple relations
const post = await Posts
.where({ id: 1 })
.include('author', 'tags', 'comments')
.first();

// ✅ Load relation on collection
const posts = await Posts
.where({ published: true })
.include('author')
.find();
posts.forEach(post => console.log(post.author.name));

// ❌ DON'T: Access unloaded relations
const post = await Posts.findById(1);
console.log(post.author.name); // May be undefined!

// ✅ DO: Load explicitly or use include
const post = await Posts.findById(1);
await post.author.load();
console.log(post.author.name);

Lazy Loading Relations

const Posts = repo.get('Posts');
const Users = repo.get('Users');

// ✅ Load one-to-many
const user = await Users.findById(1);
await user.posts.load();
console.log(`User has ${user.posts.items.length} posts`);

// ✅ Load with filters
const user = await Users.findById(1);
const publishedPosts = await user.posts.where({ published: true });

// ✅ Load many-to-many
const post = await Posts.findById(1);
await post.tags.load();
post.tags.items.forEach(tag => console.log(tag.name));

Managing Many-to-Many Relations

const Posts = repo.get('Posts');

// ✅ Add relation
const post = await Posts.findById(1);
await post.tags.add(tagId);
await post.tags.add(tagObject);

// ✅ Remove relation
await post.tags.remove(tagId);

// ✅ Replace all relations
await post.tags.set([tag1Id, tag2Id, tag3Id]);

// ✅ Clear all relations
await post.tags.set([]);

// ✅ Check if related
await post.tags.load();
const hasTag = post.tags.items.some(t => t.id === tagId);

Transactions

Basic Transaction

// ✅ Simple transaction
await repo.transaction(async tx => {
const Users = tx.get('Users');
const Posts = tx.get('Posts');

const user = await Users.create({ email: 'author@example.com' });
await Posts.create({ title: 'First Post', author_id: user.id });

// Commits automatically on success
});

// ❌ DON'T: Mix transaction contexts
await repo.transaction(async tx => {
const user = await repo.get('Users').create({ email }); // Wrong context!
const post = await tx.get('Posts').create({ author_id: user.id });
});

// ✅ DO: Use tx consistently
await repo.transaction(async tx => {
const Users = tx.get('Users');
const Posts = tx.get('Posts');

const user = await Users.create({ email });
const post = await Posts.create({ author_id: user.id });
});

Transaction with Error Handling

// ✅ Transaction with rollback on error
try {
await repo.transaction(async tx => {
const Users = tx.get('Users');
const Accounts = tx.get('Accounts');

const user = await Users.create({ email });

// This will rollback if it throws
if (someCondition) {
throw new Error('Invalid operation');
}

await Accounts.create({ user_id: user.id, balance: 0 });
});
} catch (err) {
console.error('Transaction failed:', err.message);
// All changes rolled back
}

Nested Operations in Transaction

// ✅ Complex multi-step transaction
await repo.transaction(async tx => {
const Users = tx.get('Users');
const Orders = tx.get('Orders');
const Products = tx.get('Products');
const OrderItems = tx.get('OrderItems');

// 1. Get user
const user = await Users.findById(userId);
if (!user.active) {
throw new Error('User is not active');
}

// 2. Create order
const order = await Orders.create({
customer_id: user.id,
status: 'pending',
total: 0
});

// 3. Process items
let total = 0;
for (const item of cartItems) {
const product = await Products.findById(item.product_id);

// Check stock
if (product.stock < item.quantity) {
throw new Error(`Not enough stock for ${product.name}`);
}

// Deduct inventory
await product.write({ stock: product.stock - item.quantity });

// Create order item
await OrderItems.create({
order_id: order.id,
product_id: product.id,
quantity: item.quantity,
price: product.price
});

total += product.price * item.quantity;
}

// 4. Update order total
await order.write({ total });
});

Authentication

Password Hashing (with bcrypt)

// ✅ User model with password hashing
class Users {
static _name = 'Users';
static fields = {
id: 'primary',
email: { type: 'string', unique: true, required: true },
password_hash: 'string'
};

// Set password with hashing
async setPassword(password) {
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 10);
await this.write({ password_hash: hash });
}

// Verify password
async verifyPassword(password) {
const bcrypt = require('bcrypt');
return bcrypt.compare(password, this.password_hash);
}

// Static method for authentication
static async authenticate(email, password) {
const user = await this.where({ email }).first();
if (!user) return null;

const valid = await user.verifyPassword(password);
return valid ? user : null;
}
}

// Usage
const Users = repo.get('Users');

// Register
const user = await Users.create({ email: 'john@example.com' });
await user.setPassword('secretpassword');

// Login
const user = await Users.authenticate('john@example.com', 'secretpassword');
if (!user) {
throw new Error('Invalid credentials');
}

Session Management

// ✅ Session model
class Sessions {
static _name = 'Sessions';
static fields = {
id: 'primary',
user_id: { type: 'many-to-one', model: 'Users' },
token: { type: 'string', unique: true },
expires_at: 'datetime',
created_at: { type: 'datetime', default: () => new Date() }
};

// Check if session is valid
get isValid() {
return this.expires_at > new Date();
}

// Create new session
static async createForUser(userId, ttlSeconds = 86400) {
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);

return this.create({
user_id: userId,
token,
expires_at: expiresAt
});
}

// Find by token
static async findByToken(token) {
const session = await this.where({ token }).first();
if (!session || !session.isValid) {
return null;
}
return session;
}
}

// Usage
const Sessions = repo.get('Sessions');

// Create session
const session = await Sessions.createForUser(user.id);
console.log('Session token:', session.token);

// Validate session
const session = await Sessions.findByToken(token);
if (!session) {
throw new Error('Invalid or expired session');
}

Pagination

Simple Pagination

// ✅ Basic pagination helper
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
published: 'boolean'
};

// Static pagination method
static async paginate(page = 1, perPage = 20, filters = {}) {
const offset = (page - 1) * perPage;

// Build base query
let query = this.where(filters);

// Get items and total count in parallel
const [items, total] = await Promise.all([
query.limit(perPage).offset(offset).find(),
query.count()
]);

return {
items,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
hasNext: page * perPage < total,
hasPrev: page > 1
};
}
}

// Usage
const Posts = repo.get('Posts');
const result = await Posts.paginate(2, 20, { published: true });

console.log(`Page ${result.page} of ${result.totalPages}`);
console.log(`Showing ${result.items.length} of ${result.total} posts`);
result.items.forEach(post => console.log(post.title));

Cursor-Based Pagination

// ✅ Cursor pagination for infinite scroll
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
created_at: 'datetime'
};

// Cursor-based pagination
static async paginateByCursor(cursor = null, limit = 20) {
let query = this.where({ published: true });

if (cursor) {
// Get posts created before cursor
query = query.where('created_at', '<', cursor);
}

const items = await query
.orderBy('created_at', 'desc')
.limit(limit + 1) // Get one extra to check if there's more
.find();

const hasMore = items.length > limit;
if (hasMore) items.pop(); // Remove the extra item

const nextCursor = items.length > 0
? items[items.length - 1].created_at
: null;

return {
items,
nextCursor,
hasMore
};
}
}

// Usage
const Posts = repo.get('Posts');

// First page
const page1 = await Posts.paginateByCursor();
console.log(`Loaded ${page1.items.length} posts`);

// Next page
if (page1.hasMore) {
const page2 = await Posts.paginateByCursor(page1.nextCursor);
console.log(`Loaded ${page2.items.length} more posts`);
}

Timestamps

Auto Timestamps

// ✅ Model with automatic timestamps
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
created_at: { type: 'datetime', default: () => new Date() },
updated_at: { type: 'datetime', default: () => new Date() }
};
}

// ✅ Update with timestamp
const post = await Posts.findById(1);
await post.write({
title: 'New Title',
updated_at: new Date()
});

Post-Create Hook for Timestamps

// ✅ Automatic updated_at using hooks
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
created_at: { type: 'datetime', default: () => new Date() },
updated_at: { type: 'datetime', default: () => new Date() }
};

// Post-create hook
async post_create() {
// Run after record is created
console.log(`Post ${this.id} created at ${this.created_at}`);
}

// Pre-update hook
async pre_update() {
this.updated_at = new Date();
}
}

Soft Deletes

Implementing Soft Deletes

// ✅ Soft delete pattern
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
deleted_at: 'datetime'
};

// Soft delete
async softDelete() {
await this.write({ deleted_at: new Date() });
}

// Restore
async restore() {
await this.write({ deleted_at: null });
}

// Check if deleted
get isDeleted() {
return this.deleted_at !== null;
}

// Query only active records
static active() {
return this.where({ deleted_at: null });
}

// Query only deleted records
static deleted() {
return this.where('deleted_at', 'is not', null);
}
}

// Usage
const Posts = repo.get('Posts');

// Get only active posts
const activePosts = await Posts.active().find();

// Soft delete
const post = await Posts.findById(1);
await post.softDelete();

// Restore
await post.restore();

// Get deleted posts
const deletedPosts = await Posts.deleted().find();

Slugs & SEO

Auto-Generate Slugs

// ✅ Slug generation
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: { type: 'string', required: true },
slug: { type: 'string', unique: true }
};

// Generate slug from title
static generateSlug(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}

// Ensure unique slug
static async ensureUniqueSlug(slug, excludeId = null) {
let uniqueSlug = slug;
let counter = 1;

while (true) {
let query = this.where({ slug: uniqueSlug });
if (excludeId) {
query = query.where('id', '!=', excludeId);
}

const exists = await query.count() > 0;
if (!exists) break;

uniqueSlug = `${slug}-${counter++}`;
}

return uniqueSlug;
}

// Post-create hook to generate slug
async post_create() {
if (!this.slug) {
const baseSlug = Posts.generateSlug(this.title);
const uniqueSlug = await Posts.ensureUniqueSlug(baseSlug, this.id);
await this.write({ slug: uniqueSlug });
}
}

// Find by slug
static bySlug(slug) {
return this.where({ slug }).first();
}
}

// Usage
const Posts = repo.get('Posts');

// Create with auto-slug
const post = await Posts.create({
title: 'Hello World!'
});
console.log(post.slug); // "hello-world"

// Find by slug
const post = await Posts.bySlug('hello-world');

File Uploads

File Metadata Model

// ✅ File upload model
class Uploads {
static _name = 'Uploads';
static fields = {
id: 'primary',
filename: 'string',
original_name: 'string',
mime_type: 'string',
size: 'integer',
path: 'string',
user_id: { type: 'many-to-one', model: 'Users' },
created_at: { type: 'datetime', default: () => new Date() }
};

// Get public URL
get url() {
return `/uploads/${this.filename}`;
}

// Get human-readable size
get humanSize() {
const bytes = this.size;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

// Create from file
static async createFromFile(filePath, originalName, userId) {
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const stats = fs.statSync(filePath);
const ext = path.extname(originalName);
const filename = crypto.randomBytes(16).toString('hex') + ext;

// You would move/copy the file here
// fs.renameSync(filePath, `/uploads/${filename}`);

return this.create({
filename,
original_name: originalName,
mime_type: 'application/octet-stream', // Detect from file
size: stats.size,
path: `/uploads/${filename}`,
user_id: userId
});
}
}

// Usage
const Uploads = repo.get('Uploads');

const upload = await Uploads.createFromFile(
'/tmp/upload-abc123',
'document.pdf',
userId
);

console.log(`File uploaded: ${upload.url}`);
console.log(`Size: ${upload.humanSize}`);

Validation

Field-Level Validation

// ✅ Custom validation in model
class Users {
static _name = 'Users';
static fields = {
id: 'primary',
email: { type: 'string', unique: true, required: true },
age: 'integer'
};

// Pre-create validation
async pre_create() {
this.validateEmail();
this.validateAge();
}

// Pre-update validation
async pre_update() {
if ('email' in this._changes) {
this.validateEmail();
}
if ('age' in this._changes) {
this.validateAge();
}
}

validateEmail() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.email)) {
throw new Error('Invalid email format');
}
}

validateAge() {
if (this.age !== null && this.age !== undefined) {
if (this.age < 0 || this.age > 150) {
throw new Error('Age must be between 0 and 150');
}
}
}
}

// Usage (validation happens automatically)
try {
const user = await Users.create({
email: 'invalid-email',
age: 200
});
} catch (err) {
console.error(err.message); // "Invalid email format"
}
// ✅ Basic search across multiple fields
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
content: 'text',
published: 'boolean'
};

// Search method
static search(term, filters = {}) {
const searchPattern = `%${term}%`;

return this.where({
and: [
...Object.entries(filters).map(([k, v]) => [k, '=', v]),
{
or: [
['title', 'like', searchPattern],
['content', 'like', searchPattern]
]
}
]
});
}
}

// Usage
const Posts = repo.get('Posts');

const results = await Posts
.search('javascript', { published: true })
.orderBy('created_at', 'desc')
.limit(10)
.find();

console.log(`Found ${results.length} posts matching "javascript"`);

Search with Relevance Scoring (PostgreSQL)

// ✅ Full-text search with PostgreSQL
class Posts {
static _name = 'Posts';
static fields = {
id: 'primary',
title: 'string',
content: 'text',
search_vector: 'text' // tsvector column
};

// Search with ranking
static async searchFullText(term, limit = 20) {
const query = this.query()
.select('*')
.select(this.query().raw(
"ts_rank(search_vector, plainto_tsquery('english', ?)) as rank",
[term]
))
.whereRaw(
"search_vector @@ plainto_tsquery('english', ?)",
[term]
)
.orderBy('rank', 'desc')
.limit(limit);

return query;
}

// Update search vector (trigger or hook)
async post_update() {
if ('title' in this._changes || 'content' in this._changes) {
// Update search vector for full-text search
await this.query()
.where({ id: this.id })
.update({
search_vector: this.query().raw(
"to_tsvector('english', ? || ' ' || ?)",
[this.title, this.content]
)
});
}
}
}

More Recipes

For more advanced patterns, see: