Queries & Mutations
Creating type-safe API endpoints
Queries and Mutations are the building blocks of your API. Queries are for reading data, mutations are for writing data.
Quick Start
import { defineContext, Result } from '@deessejs/functions';
import { z } from 'zod';
// Define context once
const { t, createAPI } = defineContext({
userId: "user-123",
database: myDatabase,
});
// Define a query
const getUser = t.query({
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
const user = await ctx.database.find(args.id);
if (!user) {
return Result.failure(new Error("User not found"));
}
return Result.success(user);
},
});
// Define a mutation
const createUser = t.mutation({
args: z.object({
name: z.string(),
email: z.string().email(),
}),
handler: async (ctx, args) => {
const user = await ctx.database.create(args);
return Result.success(user);
},
});
// Create the API
const api = createAPI({
getUser,
createUser,
});
// Use it
const result = await api.getUser({ id: 1 });Queries
Queries are for read operations - fetching data without side effects:
Basic Query
import { Result } from '@deessejs/functions';
const getUser = t.query({
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
const user = await ctx.database.find(args.id);
if (!user) {
return Result.failure(new Error("User not found"));
}
return Result.success(user);
},
});Query with Caching
Add cache metadata for automatic client-side caching:
const getUser = t.query({
name: 'getUser', // Optional name for debugging
cacheKey: ['users', '{id}'], // Cache key template
staleTime: 60000, // Cache for 1 minute
gcTime: 300000, // Garbage collect after 5 minutes
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
return Result.success(await ctx.database.find(args.id));
},
});Cache Key Features:
- Template interpolation:
['users', '{id}']→['users', '123'] - Automatic patterns:
['users', '{id}']matches['users', '*'] - Type-safe: Keys inferred from args schema
Query Features
- Read-only - Should not modify data
- Idempotent - Calling multiple times has same effect
- Cacheable - Results cached automatically with cache metadata
- Type-safe args - Validated with Zod schemas
Mutations
Mutations are for write operations - modifying data:
Basic Mutation
const createUser = t.mutation({
args: z.object({
name: z.string().min(2),
email: z.string().email(),
}),
handler: async (ctx, args) => {
// Check if user exists
const existing = await ctx.database.findByEmail(args.email);
if (existing) {
return Result.failure(new Error("Email already exists"));
}
// Create user
const user = await ctx.database.create(args);
return Result.success(user);
},
});Mutation with Cache Invalidation
Automatically invalidate queries after mutations:
const updateUser = t.mutation({
name: 'updateUser',
invalidate: [
['users', '{id}'], // Invalidate this specific user
['users'], // Invalidate all users list
['user-feed', '{id}'], // Invalidate user's feed
],
args: z.object({
id: z.number(),
name: z.string(),
}),
handler: async (ctx, args) => {
const user = await ctx.database.update(args.id, args);
return Result.success(user);
},
});Invalidation Options:
const mutation = t.mutation({
invalidate: [
['exact', 'key'], // Exact match only
['prefix', 'users'], // All keys starting with 'users'
['pattern', 'users:*:settings'], // Glob pattern
['tags', 'users'], // All with 'users' tag
],
});Mutation Features
- Side effects - Can modify data
- Not idempotent - Calling multiple times may have different effects
- Invalidates cache - Automatic cache invalidation with metadata
- Type-safe args - Validated with Zod schemas
Input Validation
All args are validated using Zod schemas:
const getProduct = t.query({
args: z.object({
id: z.number().positive(),
includeReviews: z.boolean().optional(),
}),
handler: async (ctx, args) => {
// args.id is guaranteed to be a positive number
// args.includeReviews is optional boolean
const product = await ctx.db.find(args.id, {
reviews: args.includeReviews,
});
return Result.success(product);
},
});
// Invalid input automatically returns validation error
const result = await getProduct({ id: -5 }); // ❌ Validation errorCustom Validation
const registerUser = t.mutation({
args: z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18),
}),
handler: async (ctx, args) => {
// Custom business validation
const existing = await ctx.db.findByEmail(args.email);
if (existing) {
return Result.failure(new Error("Email already registered"));
}
const user = await ctx.db.create(args);
return Result.success(user);
},
});Organizing Endpoints
Use routers to organize related endpoints:
const api = createAPI({
users: t.router({
get: t.query({ /* ... */ }),
list: t.query({ /* ... */ }),
create: t.mutation({ /* ... */ }),
update: t.mutation({ /* ... */ }),
delete: t.mutation({ /* ... */ }),
}),
posts: t.router({
get: t.query({ /* ... */ }),
list: t.query({ /* ... */ }),
create: t.mutation({ /* ... */ }),
}),
comments: t.router({
get: t.query({ /* ... */ }),
create: t.mutation({ /* ... */ }),
}),
});Usage:
// Call endpoints
const user = await api.users.get({ id: 123 });
const posts = await api.posts.list({});
const comment = await api.comments.create({
postId: 456,
content: "Great post!",
});Nesting Routers
Create deep nesting for complex APIs:
const api = createAPI({
admin: t.router({
users: t.router({
list: t.query({ /* ... */ }),
ban: t.mutation({ /* ... */ }),
unban: t.mutation({ /* ... */ }),
}),
settings: t.router({
get: t.query({ /* ... */ }),
update: t.mutation({ /* ... */ }),
}),
}),
});
// Usage
const banned = await api.admin.users.ban({ userId: 123 });Best Practices
1. Use Queries for Reads
// ✅ Good - query for reading
const getUser = t.query({
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
return Result.success(await ctx.db.find(args.id));
},
});
// ❌ Bad - mutation for reading
const getUser = t.mutation({
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
return Result.success(await ctx.db.find(args.id));
},
});2. Use Mutations for Writes
// ✅ Good - mutation for writing
const createUser = t.mutation({
args: z.object({ name: z.string() }),
handler: async (ctx, args) => {
return Result.success(await ctx.db.create(args));
},
});3. Validate Args Early
// ✅ Good - Zod validates automatically
const createPost = t.mutation({
args: z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
}),
handler: async (ctx, args) => {
// args are guaranteed to be valid
return Result.success(await ctx.db.create(args));
},
});4. Return Descriptive Errors
// ✅ Good - descriptive error messages
const transfer = t.mutation({
args: z.object({
from: z.number(),
to: z.number(),
amount: z.number(),
}),
handler: async (ctx, args) => {
const balance = await ctx.db.getBalance(args.from);
if (balance < args.amount) {
return Result.failure(new Error(
`Insufficient funds. Balance: ${balance}, Required: ${args.amount}`
));
}
// ... transfer logic
},
});5. Use Cache Metadata
// ✅ Good - add cache metadata for queries
const getUser = t.query({
cacheKey: ['users', '{id}'],
staleTime: 60000, // 1 minute
gcTime: 300000, // 5 minutes
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
return Result.success(await ctx.db.find(args.id));
},
});
// ✅ Good - invalidate cache in mutations
const updateUser = t.mutation({
invalidate: [['users', '{id}']],
args: z.object({ id: z.number(), name: z.string() }),
handler: async (ctx, args) => {
return Result.success(await ctx.db.update(args.id, args));
},
});Real-World Example
import { defineContext, Result } from '@deessejs/functions';
import { z } from 'zod';
const { t, createAPI } = defineContext({
userId: "user-123",
database: myDatabase,
});
// Queries with caching
const getUser = t.query({
cacheKey: ['users', '{id}'],
staleTime: 60000,
args: z.object({ id: z.number() }),
handler: async (ctx, args) => {
const user = await ctx.database.users.find(args.id);
if (!user) {
return Result.failure(new Error("User not found"));
}
// Hide sensitive data
return Result.success({
id: user.id,
name: user.name,
email: user.email,
});
},
});
const listPosts = t.query({
cacheKey: ['posts'],
staleTime: 30000, // 30 seconds
args: z.object({
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
handler: async (ctx, args) => {
const posts = await ctx.database.posts.findAll({
limit: args.limit,
offset: args.offset,
});
return Result.success(posts);
},
});
// Mutations with invalidation
const createUser = t.mutation({
invalidate: [['posts']], // Invalidate posts list
args: z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
}),
handler: async (ctx, args) => {
// Check if email exists
const existing = await ctx.database.users.findByEmail(args.email);
if (existing) {
return Result.failure(new Error("Email already registered"));
}
// Hash password
const hashed = await hashPassword(args.password);
// Create user
const user = await ctx.database.users.create({
name: args.name,
email: args.email,
passwordHash: hashed,
});
// Return without password
return Result.success({
id: user.id,
name: user.name,
email: user.email,
});
},
});
const createPost = t.mutation({
invalidate: [
['posts'],
['user-feed', '{userId}'],
],
args: z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
}),
handler: async (ctx, args) => {
const post = await ctx.database.posts.create({
title: args.title,
content: args.content,
published: args.published,
authorId: ctx.userId,
createdAt: new Date(),
});
return Result.success(post);
},
});
// Create API
const api = createAPI({
users: t.router({
get: getUser,
create: createUser,
}),
posts: t.router({
list: listPosts,
create: createPost,
}),
});See Also
- Context System - Managing shared state
- Validation - Input validation with Zod
- Retry - Automatic retry logic for resilient operations
- Extensions - Augmenting context with extensions