Functions

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 error

Custom 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

On this page