DeesseJS RPC

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:

api.ts
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:

api.ts
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:

PartDescription
tObject returned by defineContext with procedure builders
t.query()Higher-order function that returns a procedure
argsZod schema for input validation
handlerAsync function (ctx, args) => Result
Return valueA 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:

  1. Register it in a router:

    api.ts
    export const appRouter = t.router({
      users: t.router({
        get: getUser,
      }),
    })
  2. 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:

FormatExampleUse 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:

api.ts
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

On this page