Queries
Fetch data from your API without modifying it. Queries are exposed as HTTP GET endpoints with automatic caching support.
Queries are public read operations exposed via HTTP. You use them to fetch data without modifying it. The framework handles caching automatically based on the keys you return.
Defining a Query
When you call defineContext, it returns an object containing t. This t object provides procedure builders:
import { defineContext } from '@deessejs/server'
const { t, createAPI } = defineContext({
context: () => ({ db: myDatabase }),
})Query Builder
The t.query() function is a higher-order function. It takes a definition object with args and handler, and returns a procedure you can call or register in a router:
import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { NotFoundException } from '@deessejs/server'
const { t, createAPI } = defineContext({
context: () => ({ db: myDatabase }),
})
const getUser = t.query({
args: z.object({
id: z.number(),
}),
handler: async (ctx, args) => {
const user = await ctx.db.users.find(args.id)
if (!user) {
return err(new NotFoundException('User not found'))
}
return ok(user, { keys: [['users', { id: args.id }]] })
},
})How it works:
| Part | Description |
|---|---|
t | Object returned by defineContext with procedure builders |
t.query() | Higher-order function that returns a procedure |
args | Zod schema for input validation |
handler | Async function (ctx, args) => Result |
| Return value | A callable procedure you can add to a router |
What you get back:
The t.query() function returns a procedure - a function with metadata attached. You can:
-
Register it in a router:
api.ts export const appRouter = t.router({ users: t.router({ get: getUser, }), }) -
Call it directly (for testing or internal use):
const result = await getUser(ctx, { id: 42 })
Query Properties
Prop
Type
Return Value
To return results with cache keys, use the ok() helper from @deessejs/fp:
return ok(result, { keys: [['users', { id: args.id }]] })The keys option tells the cache system which entries to store or retrieve:
| Format | Example | Use Case |
|---|---|---|
['entity', { filter }] | ['users', { id: 1 }] | Entity lookup |
['entity', id] | ['users', 42] | Simple ID lookup |
['entity', id, 'relation'] | ['users', 1, 'posts'] | Nested relations |
Complete Example
The example below shows a full queries implementation with getUser and listUsers. You can see how to structure query definitions, handle errors with err(), and return typed results with cache keys:
import { defineContext } from '@deessejs/server'
import { z } from 'zod'
import { ok, err } from '@deessejs/fp'
import { NotFoundException } from '@deessejs/server'
const { t, createAPI } = defineContext({
context: () => ({ db: myDatabase }),
})
const getUser = t.query({
args: z.object({
id: z.number(),
include: z.array(z.string()).optional().default([]),
}),
handler: async (ctx, args) => {
const user = await ctx.db.users.find(args.id)
if (!user) {
return err(new NotFoundException(`User ${args.id} not found`))
}
let data = { ...user }
if (args.include.includes('posts')) {
data.posts = await ctx.db.posts.findByUser(args.id)
}
return ok(data, {
keys: [
['users', { id: args.id }],
...(args.include.includes('posts') ? [['users', args.id, 'posts']] : []),
],
})
},
})
const listUsers = t.query({
args: z.object({
limit: z.number().min(1).max(100).default(20),
offset: z.number().default(0),
}),
handler: async (ctx, args) => {
const users = await ctx.db.users.findMany({
limit: args.limit,
offset: args.offset,
})
return ok(users, { keys: ['users:list'] })
},
})
export const appRouter = t.router({
users: t.router({
get: getUser,
list: listUsers,
}),
})Anti-Patterns
Don't Use Queries for Writes
Queries should only read data, never modify it. When you need to write, use a mutation instead:
import { defineContext, t } from '@deessejs/server'
import { z } from 'zod'
import { ok } from '@deessejs/fp'
// Don't - queries should not modify data
const updateUser = t.query({
args: z.object({ id: z.number(), name: z.string() }),
handler: async (ctx, args) => {
await ctx.db.users.update(args.id, { name: args.name })
return ok({ success: true })
},
})
// Do - use mutations for writes
const updateUser = t.mutation({
args: z.object({ id: z.number(), name: z.string() }),
handler: async (ctx, args) => {
await ctx.db.users.update(args.id, { name: args.name })
return ok({ success: true }, { invalidate: [['users', { id: args.id }]] })
},
})Don't Return Untyped Data
Always return typed data from your handlers to maintain type safety throughout your application:
// Don't - no type safety
handler: async (ctx, args) => {
return ok(await ctx.db.rawQuery('SELECT * FROM users'))
}
// Do - return typed data
handler: async (ctx, args) => {
const users = await ctx.db.query<User>('SELECT * FROM users')
return ok(users)
}Don't Forget Cache Keys
When you return results without cache keys, the framework cannot cache them automatically. Always specify cache keys to enable caching:
// Don't - no caching
handler: async (ctx, args) => {
return ok(await ctx.db.users.find(args.id))
}
// Do - specify cache keys
handler: async (ctx, args) => {
const user = await ctx.db.users.find(args.id)
return ok(user, { keys: [['users', { id: args.id }]] })
}Best Practices
1. Return Errors with err()
When a query cannot find the requested data, return an error using the err() helper instead of throwing exceptions:
handler: async (ctx, args) => {
const user = await ctx.db.users.find(args.id)
if (!user) {
return err(new NotFoundException('User not found'))
}
return ok(user)
}2. Use Appropriate Cache Keys
When you define cache keys, match them to how your code accesses the data to ensure cache hits:
// Single user lookup - entity with filter
return ok(user, { keys: [['users', { id: args.id }]] })
// List all users - simple key
return ok(users, { keys: ['users:list'] })
// Nested access - include relation path
return ok(posts, { keys: ['users', args.id, 'posts'] })3. Validate Input with Zod
Use Zod's full validation power:
args: z.object({
id: z.number().int().positive(),
email: z.string().email().optional(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
createdAfter: z.string().datetime().optional(),
})Next Steps
- Mutations - Define write operations
- Internal Procedures - Server-only operations
- Routers - Organize procedures into namespaces