Custom Queries Tutorial

Create powerful custom API endpoints beyond basic CRUD operations - complex joins, aggregations, search, and batch operations

Custom Queries Tutorial

Learn how to create powerful custom API endpoints beyond basic CRUD operations using DataBridge custom queries.

Table of Contents

  1. Introduction
  2. When to Use Custom Queries
  3. Query Definition Syntax
  4. Parameter Types and Validation
  5. Authentication & Authorization
  6. Real-World Examples
  7. Best Practices
  8. Troubleshooting

Introduction

Custom queries allow you to define complex API endpoints that go beyond the standard CRUD operations generated by DataBridge. They give you full control over:

  • Custom business logic - Implement complex operations across multiple models
  • Aggregations & statistics - Calculate counts, averages, sums, etc.
  • Search & filtering - Complex queries with dynamic conditions
  • Batch operations - Create/update multiple records in one request
  • Custom validations - Add business rules beyond schema constraints

When to Use Custom Queries

✅ Use Custom Queries For:

  • Complex joins across multiple tables
  • Aggregations and statistics (counts, averages, totals)
  • Search endpoints with multiple filter options
  • Batch operations (bulk create, update, delete)
  • Custom business logic that doesn’t fit CRUD
  • Performance-critical queries using raw SQL
  • Third-party integrations (webhooks, external APIs)

❌ Use Standard CRUD For:

  • Simple create, read, update, delete operations
  • Single-record operations by ID
  • Basic filtering and pagination
  • Standard list endpoints with sorting

Example Decision:

// ❌ Don't use custom query - use standard CRUD
'getUserById': {
  method: 'GET',
  path: '/users/:id',
  handler: async (prisma, { params }) => {
    return prisma.user.findUnique({ where: { id: params.id } });
  },
}

// ✅ Use custom query - complex logic
'getUserDashboard': {
  method: 'GET',
  path: '/users/:id/dashboard',
  handler: async (prisma, { params }) => {
    const [user, orders, stats] = await Promise.all([
      prisma.user.findUnique({ where: { id: params.id } }),
      prisma.order.findMany({ where: { userId: params.id }, take: 5 }),
      prisma.order.aggregate({
        where: { userId: params.id },
        _count: true,
        _sum: { total: true },
      }),
    ]);
    return { user, recentOrders: orders, totalSpent: stats._sum.total };
  },
}

Query Definition Syntax

A custom query definition consists of these fields:

import { defineQueries } from './databridge.types';

export default defineQueries({
  'queryName': {
    // Required fields
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    path: '/custom/path',
    handler: async (prisma, context) => { /* ... */ },
    
    // Optional fields
    description: 'Human-readable description',
    tags: ['Custom', 'Analytics'],
    params: { /* path parameters */ },
    query: { /* query string parameters */ },
    body: { /* request body schema */ },
    responseType: 'UserPublic', // Use a custom schema
    statusCode: 200,
    auth: true,
  },
});

Field Reference

FieldTypeRequiredDescription
methodstringHTTP method: GET, POST, PUT, PATCH, DELETE
pathstringAPI endpoint path (must start with /)
handlerfunctionAsync function that implements the query
descriptionstringDescription for OpenAPI docs
tagsstring[]OpenAPI tags for grouping
paramsobjectPath parameter definitions
queryobjectQuery string parameter definitions
bodyobjectRequest body schema
responseTypestringCustom schema name for response
statusCodenumberHTTP status code (default: 200)
authbooleanRequire authentication

Parameter Types and Validation

DataBridge uses Zod for runtime validation of request parameters.

Parameter Definition

{
  paramName: {
    type: 'string' | 'number' | 'boolean' | 'array',
    required: boolean,
    default: any,
    enum: any[],      // For strings only
    min: number,      // For numbers only
    max: number,      // For numbers only
    pattern: string,  // For strings only
  }
}

Path Parameters

'getOrderDetails': {
  method: 'GET',
  path: '/users/:userId/orders/:orderId',
  
  params: {
    userId: {
      type: 'number',
      required: true,
    },
    orderId: {
      type: 'number',
      required: true,
    },
  },
  
  handler: async (prisma, { params }) => {
    // params.userId and params.orderId are validated as numbers
    return prisma.order.findFirst({
      where: {
        id: params.orderId,
        userId: params.userId,
      },
    });
  },
}

Query String Parameters

'searchProducts': {
  method: 'GET',
  path: '/products/search',
  
  query: {
    q: {
      type: 'string',
      required: true,
      min: 2,
    },
    category: {
      type: 'string',
      enum: ['electronics', 'clothing', 'books'],
    },
    minPrice: {
      type: 'number',
      min: 0,
      default: 0,
    },
    maxPrice: {
      type: 'number',
      min: 0,
    },
    inStock: {
      type: 'boolean',
      default: false,
    },
  },
  
  handler: async (prisma, { query }) => {
    return prisma.product.findMany({
      where: {
        name: { contains: query.q },
        ...(query.category && { category: query.category }),
        price: {
          gte: query.minPrice,
          ...(query.maxPrice && { lte: query.maxPrice }),
        },
        ...(query.inStock && { stock: { gt: 0 } }),
      },
    });
  },
}

Request Body

'createOrder': {
  method: 'POST',
  path: '/orders',
  
  body: {
    userId: {
      type: 'number',
      required: true,
    },
    items: {
      type: 'array',
      required: true,
      items: {
        productId: { type: 'number', required: true },
        quantity: { type: 'number', required: true, min: 1 },
        notes: { type: 'string' },
      },
    },
    shippingAddress: {
      street: { type: 'string', required: true },
      city: { type: 'string', required: true },
      zipCode: { type: 'string', required: true, pattern: '^[0-9]{5}$' },
    },
  },
  
  handler: async (prisma, { body }) => {
    return prisma.order.create({
      data: {
        userId: body.userId,
        items: {
          create: body.items.map(item => ({
            productId: item.productId,
            quantity: item.quantity,
            notes: item.notes,
          })),
        },
        shippingAddress: {
          create: body.shippingAddress,
        },
      },
      include: {
        items: { include: { product: true } },
        shippingAddress: true,
      },
    });
  },
}

Authentication & Authorization

Basic Authentication

'getMyProfile': {
  method: 'GET',
  path: '/me/profile',
  auth: true,  // Requires authentication
  
  handler: async (prisma, { user }) => {
    // user is available from JWT token
    return prisma.user.findUnique({
      where: { id: user.id },
      include: { profile: true },
    });
  },
}

Role-Based Authorization

'deleteUser': {
  method: 'DELETE',
  path: '/admin/users/:userId',
  auth: true,
  
  handler: async (prisma, { params, user }) => {
    // Check if user has admin role
    if (user.role !== 'ADMIN') {
      throw new Error('Unauthorized: Admin role required');
    }
    
    return prisma.user.delete({
      where: { id: params.userId },
    });
  },
}

Resource Ownership Check

'updateMyOrder': {
  method: 'PUT',
  path: '/me/orders/:orderId',
  auth: true,
  
  body: {
    status: {
      type: 'string',
      enum: ['cancelled'],
    },
  },
  
  handler: async (prisma, { params, body, user }) => {
    // Verify order belongs to user
    const order = await prisma.order.findFirst({
      where: {
        id: params.orderId,
        userId: user.id,
      },
    });
    
    if (!order) {
      throw new Error('Order not found or access denied');
    }
    
    return prisma.order.update({
      where: { id: params.orderId },
      data: { status: body.status },
    });
  },
}

Real-World Examples

1. User Statistics Dashboard

'getUserStats': {
  method: 'GET',
  path: '/users/:userId/stats',
  
  params: {
    userId: { type: 'number', required: true },
  },
  
  query: {
    period: {
      type: 'string',
      enum: ['7d', '30d', '90d', '1y'],
      default: '30d',
    },
  },
  
  handler: async (prisma, { params, query }) => {
    const periodDays = {
      '7d': 7,
      '30d': 30,
      '90d': 90,
      '1y': 365,
    }[query.period];
    
    const since = new Date();
    since.setDate(since.getDate() - periodDays);
    
    const [orderCount, orderTotal, avgOrderValue, topProducts] = await Promise.all([
      prisma.order.count({
        where: { userId: params.userId, createdAt: { gte: since } },
      }),
      prisma.order.aggregate({
        where: { userId: params.userId, createdAt: { gte: since } },
        _sum: { total: true },
      }),
      prisma.order.aggregate({
        where: { userId: params.userId, createdAt: { gte: since } },
        _avg: { total: true },
      }),
      prisma.orderItem.groupBy({
        by: ['productId'],
        where: {
          order: {
            userId: params.userId,
            createdAt: { gte: since },
          },
        },
        _sum: { quantity: true },
        _count: true,
        orderBy: { _sum: { quantity: 'desc' } },
        take: 5,
      }),
    ]);
    
    return {
      period: query.period,
      orderCount,
      totalSpent: orderTotal._sum.total || 0,
      averageOrderValue: avgOrderValue._avg.total || 0,
      topProducts,
    };
  },
}

2. Advanced Search with Filters

'searchOrders': {
  method: 'GET',
  path: '/orders/search',
  
  query: {
    // Text search
    customerEmail: { type: 'string' },
    
    // Status filters
    status: {
      type: 'string',
      enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
    },
    
    // Date range
    startDate: { type: 'string' },
    endDate: { type: 'string' },
    
    // Price range
    minTotal: { type: 'number', min: 0 },
    maxTotal: { type: 'number', min: 0 },
    
    // Pagination
    page: { type: 'number', min: 1, default: 1 },
    limit: { type: 'number', min: 1, max: 100, default: 20 },
    
    // Sorting
    sortBy: {
      type: 'string',
      enum: ['createdAt', 'total', 'status'],
      default: 'createdAt',
    },
    sortOrder: {
      type: 'string',
      enum: ['asc', 'desc'],
      default: 'desc',
    },
  },
  
  handler: async (prisma, { query }) => {
    const where: any = {};
    
    // Build dynamic where clause
    if (query.customerEmail) {
      where.user = {
        email: { contains: query.customerEmail },
      };
    }
    
    if (query.status) {
      where.status = query.status;
    }
    
    if (query.startDate || query.endDate) {
      where.createdAt = {};
      if (query.startDate) where.createdAt.gte = new Date(query.startDate);
      if (query.endDate) where.createdAt.lte = new Date(query.endDate);
    }
    
    if (query.minTotal || query.maxTotal) {
      where.total = {};
      if (query.minTotal) where.total.gte = query.minTotal;
      if (query.maxTotal) where.total.lte = query.maxTotal;
    }
    
    const skip = (query.page - 1) * query.limit;
    
    const [orders, total] = await Promise.all([
      prisma.order.findMany({
        where,
        include: {
          user: { select: { email: true, name: true } },
          items: { include: { product: true } },
        },
        orderBy: { [query.sortBy]: query.sortOrder },
        skip,
        take: query.limit,
      }),
      prisma.order.count({ where }),
    ]);
    
    return {
      data: orders,
      pagination: {
        page: query.page,
        limit: query.limit,
        total,
        pages: Math.ceil(total / query.limit),
      },
    };
  },
}

3. Batch Create with Transactions

'createOrderWithItems': {
  method: 'POST',
  path: '/orders/batch',
  
  body: {
    userId: { type: 'number', required: true },
    items: {
      type: 'array',
      required: true,
      items: {
        productId: { type: 'number', required: true },
        quantity: { type: 'number', required: true, min: 1 },
        price: { type: 'number', required: true, min: 0 },
      },
    },
    paymentMethod: {
      type: 'string',
      enum: ['credit_card', 'paypal', 'bank_transfer'],
      required: true,
    },
  },
  
  handler: async (prisma, { body }) => {
    // Use transaction to ensure all-or-nothing
    return await prisma.$transaction(async (tx) => {
      // Verify all products exist and have sufficient stock
      for (const item of body.items) {
        const product = await tx.product.findUnique({
          where: { id: item.productId },
        });
        
        if (!product) {
          throw new Error(`Product ${item.productId} not found`);
        }
        
        if (product.stock < item.quantity) {
          throw new Error(`Insufficient stock for product ${product.name}`);
        }
      }
      
      // Calculate total
      const total = body.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
      
      // Create order
      const order = await tx.order.create({
        data: {
          userId: body.userId,
          total,
          paymentMethod: body.paymentMethod,
          status: 'pending',
        },
      });
      
      // Create order items and update stock
      for (const item of body.items) {
        await tx.orderItem.create({
          data: {
            orderId: order.id,
            productId: item.productId,
            quantity: item.quantity,
            price: item.price,
          },
        });
        
        await tx.product.update({
          where: { id: item.productId },
          data: { stock: { decrement: item.quantity } },
        });
      }
      
      // Return order with items
      return tx.order.findUnique({
        where: { id: order.id },
        include: {
          items: { include: { product: true } },
          user: true,
        },
      });
    });
  },
}

4. Aggregation Report

'getSalesReport': {
  method: 'GET',
  path: '/reports/sales',
  auth: true,
  
  query: {
    startDate: { type: 'string', required: true },
    endDate: { type: 'string', required: true },
    groupBy: {
      type: 'string',
      enum: ['day', 'week', 'month'],
      default: 'day',
    },
  },
  
  handler: async (prisma, { query, user }) => {
    // Admin only
    if (user.role !== 'ADMIN') {
      throw new Error('Unauthorized');
    }
    
    const startDate = new Date(query.startDate);
    const endDate = new Date(query.endDate);
    
    // Get all orders in date range
    const orders = await prisma.order.findMany({
      where: {
        createdAt: {
          gte: startDate,
          lte: endDate,
        },
        status: { in: ['delivered', 'shipped'] },
      },
      select: {
        createdAt: true,
        total: true,
        items: {
          select: {
            quantity: true,
            product: {
              select: {
                category: true,
              },
            },
          },
        },
      },
    });
    
    // Group by time period
    const groupedSales: Record<string, { revenue: number; orders: number; items: number }> = {};
    
    for (const order of orders) {
      let key: string;
      if (query.groupBy === 'day') {
        key = order.createdAt.toISOString().split('T')[0];
      } else if (query.groupBy === 'week') {
        const weekNum = Math.floor(order.createdAt.getTime() / (7 * 24 * 60 * 60 * 1000));
        key = `Week ${weekNum}`;
      } else {
        key = order.createdAt.toISOString().slice(0, 7);
      }
      
      if (!groupedSales[key]) {
        groupedSales[key] = { revenue: 0, orders: 0, items: 0 };
      }
      
      groupedSales[key].revenue += order.total;
      groupedSales[key].orders += 1;
      groupedSales[key].items += order.items.reduce((sum, item) => sum + item.quantity, 0);
    }
    
    // Calculate category breakdown
    const categoryRevenue: Record<string, number> = {};
    for (const order of orders) {
      for (const item of order.items) {
        const category = item.product.category || 'Unknown';
        categoryRevenue[category] = (categoryRevenue[category] || 0) + (order.total / order.items.length);
      }
    }
    
    return {
      period: {
        start: query.startDate,
        end: query.endDate,
        groupBy: query.groupBy,
      },
      timeline: groupedSales,
      categoryBreakdown: categoryRevenue,
      totals: {
        revenue: orders.reduce((sum, o) => sum + o.total, 0),
        orders: orders.length,
        averageOrderValue: orders.length > 0
          ? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
          : 0,
      },
    };
  },
}

5. Export Data to CSV

'exportOrders': {
  method: 'GET',
  path: '/orders/export',
  auth: true,
  
  query: {
    format: {
      type: 'string',
      enum: ['csv', 'json'],
      default: 'csv',
    },
    startDate: { type: 'string' },
    endDate: { type: 'string' },
  },
  
  handler: async (prisma, { query, user, reply }) => {
    // Admin only
    if (user.role !== 'ADMIN') {
      throw new Error('Unauthorized');
    }
    
    const where: any = {};
    if (query.startDate || query.endDate) {
      where.createdAt = {};
      if (query.startDate) where.createdAt.gte = new Date(query.startDate);
      if (query.endDate) where.createdAt.lte = new Date(query.endDate);
    }
    
    const orders = await prisma.order.findMany({
      where,
      include: {
        user: { select: { email: true, name: true } },
        items: { include: { product: true } },
      },
      orderBy: { createdAt: 'desc' },
    });
    
    if (query.format === 'json') {
      return orders;
    }
    
    // Generate CSV
    const csv = [
      'Order ID,Date,Customer Email,Customer Name,Status,Total,Items',
      ...orders.map(order => [
        order.id,
        order.createdAt.toISOString(),
        order.user.email,
        order.user.name,
        order.status,
        order.total,
        order.items.length,
      ].join(',')),
    ].join('\n');
    
    reply.header('Content-Type', 'text/csv');
    reply.header('Content-Disposition', 'attachment; filename="orders.csv"');
    return csv;
  },
}

6. Webhook Handler

'stripeWebhook': {
  method: 'POST',
  path: '/webhooks/stripe',
  
  // No validation - Stripe sends raw body
  handler: async (prisma, { request, reply }) => {
    const signature = request.headers['stripe-signature'];
    const rawBody = await request.raw(); // Get raw request body
    
    // Verify webhook signature (pseudo-code)
    // const event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
    
    // Handle different event types
    const event = JSON.parse(rawBody.toString());
    
    switch (event.type) {
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object;
        
        // Update order status
        await prisma.order.updateMany({
          where: { paymentIntentId: paymentIntent.id },
          data: { status: 'paid' },
        });
        break;
        
      case 'payment_intent.payment_failed':
        const failedPayment = event.data.object;
        
        await prisma.order.updateMany({
          where: { paymentIntentId: failedPayment.id },
          data: { status: 'payment_failed' },
        });
        break;
    }
    
    return { received: true };
  },
}

7. GraphQL-Style Nested Query

'getOrderWithEverything': {
  method: 'GET',
  path: '/orders/:orderId/full',
  
  params: {
    orderId: { type: 'number', required: true },
  },
  
  query: {
    // Allow client to specify what to include
    includeUser: { type: 'boolean', default: true },
    includeItems: { type: 'boolean', default: true },
    includeShipping: { type: 'boolean', default: false },
    includePayment: { type: 'boolean', default: false },
  },
  
  handler: async (prisma, { params, query }) => {
    const include: any = {};
    
    if (query.includeUser) {
      include.user = {
        select: {
          id: true,
          email: true,
          name: true,
          // Don't include sensitive data
        },
      };
    }
    
    if (query.includeItems) {
      include.items = {
        include: {
          product: {
            select: {
              id: true,
              name: true,
              price: true,
              imageUrl: true,
            },
          },
        },
      };
    }
    
    if (query.includeShipping) {
      include.shippingAddress = true;
    }
    
    if (query.includePayment) {
      include.payment = {
        select: {
          method: true,
          status: true,
          // Don't include card details
        },
      };
    }
    
    return prisma.order.findUnique({
      where: { id: params.orderId },
      include,
    });
  },
}

8. Scheduled Task Endpoint

'sendOrderReminders': {
  method: 'POST',
  path: '/cron/order-reminders',
  auth: true,
  
  handler: async (prisma, { user }) => {
    // Verify this is a system/cron user
    if (user.role !== 'SYSTEM') {
      throw new Error('Unauthorized');
    }
    
    // Find abandoned carts (orders pending for > 24 hours)
    const oneDayAgo = new Date();
    oneDayAgo.setDate(oneDayAgo.getDate() - 1);
    
    const abandonedOrders = await prisma.order.findMany({
      where: {
        status: 'pending',
        createdAt: { lte: oneDayAgo },
        reminderSent: false,
      },
      include: {
        user: { select: { email: true, name: true } },
        items: { include: { product: true } },
      },
    });
    
    // Send reminder emails
    const emailsSent: number[] = [];
    for (const order of abandonedOrders) {
      // Send email (pseudo-code)
      // await sendEmail({
      //   to: order.user.email,
      //   subject: 'Complete your order',
      //   template: 'abandoned-cart',
      //   data: { order },
      // });
      
      // Mark reminder as sent
      await prisma.order.update({
        where: { id: order.id },
        data: { reminderSent: true },
      });
      
      emailsSent.push(order.id);
    }
    
    return {
      processed: abandonedOrders.length,
      emailsSent,
    };
  },
}

9. Soft Delete Pattern

'deleteProduct': {
  method: 'DELETE',
  path: '/products/:productId',
  auth: true,
  
  params: {
    productId: { type: 'number', required: true },
  },
  
  query: {
    // Allow hard delete for admins
    hard: { type: 'boolean', default: false },
  },
  
  handler: async (prisma, { params, query, user }) => {
    if (query.hard) {
      // Admin only for hard deletes
      if (user.role !== 'ADMIN') {
        throw new Error('Unauthorized: Admin role required for hard delete');
      }
      
      return prisma.product.delete({
        where: { id: params.productId },
      });
    }
    
    // Soft delete - mark as deleted
    return prisma.product.update({
      where: { id: params.productId },
      data: {
        deletedAt: new Date(),
        active: false,
      },
    });
  },
}

10. Health Check with Database Stats

'healthCheck': {
  method: 'GET',
  path: '/health',
  
  handler: async (prisma) => {
    try {
      // Check database connection
      await prisma.$queryRaw`SELECT 1`;
      
      // Get some stats
      const [userCount, productCount, orderCount] = await Promise.all([
        prisma.user.count(),
        prisma.product.count(),
        prisma.order.count(),
      ]);
      
      return {
        status: 'healthy',
        timestamp: new Date().toISOString(),
        database: {
          connected: true,
          stats: {
            users: userCount,
            products: productCount,
            orders: orderCount,
          },
        },
        uptime: process.uptime(),
        memory: {
          used: process.memoryUsage().heapUsed / 1024 / 1024,
          total: process.memoryUsage().heapTotal / 1024 / 1024,
        },
      };
    } catch (error) {
      return {
        status: 'unhealthy',
        error: error.message,
        timestamp: new Date().toISOString(),
      };
    }
  },
}

Best Practices

1. Input Validation

Always validate all user input:

// ✅ Good - explicit validation
query: {
  page: { type: 'number', min: 1, default: 1 },
  limit: { type: 'number', min: 1, max: 100, default: 20 },
  sortOrder: { type: 'string', enum: ['asc', 'desc'], default: 'asc' },
}

// ❌ Bad - no validation
query: {
  page: { type: 'number' }, // Could be negative or 0
  limit: { type: 'number' }, // Could be 999999
  sortOrder: { type: 'string' }, // Could be 'DROP TABLE'
}

2. Error Handling

Provide meaningful error messages:

// ✅ Good - specific errors
if (!order) {
  throw new Error('Order not found');
}
if (order.userId !== user.id) {
  throw new Error('You do not have permission to view this order');
}

// ❌ Bad - generic errors
if (!order || order.userId !== user.id) {
  throw new Error('Error');
}

3. Performance

Optimize database queries:

// ✅ Good - single query with includes
const order = await prisma.order.findUnique({
  where: { id: orderId },
  include: {
    user: true,
    items: { include: { product: true } },
  },
});

// ❌ Bad - N+1 query problem
const order = await prisma.order.findUnique({ where: { id: orderId } });
const user = await prisma.user.findUnique({ where: { id: order.userId } });
const items = await prisma.orderItem.findMany({ where: { orderId } });
for (const item of items) {
  item.product = await prisma.product.findUnique({ where: { id: item.productId } });
}

4. Security

Never trust user input:

// ✅ Good - sanitize and validate
handler: async (prisma, { query }) => {
  const email = query.email.trim().toLowerCase();
  if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
    throw new Error('Invalid email format');
  }
  // ... proceed with query
}

// ❌ Bad - raw user input in query
handler: async (prisma, { query }) => {
  return prisma.$queryRaw`SELECT * FROM users WHERE email = '${query.email}'`;
}

5. Transactions

Use transactions for multi-step operations:

// ✅ Good - transaction ensures atomicity
return await prisma.$transaction(async (tx) => {
  const order = await tx.order.create({ data: orderData });
  await tx.product.update({
    where: { id: productId },
    data: { stock: { decrement: quantity } },
  });
  return order;
});

// ❌ Bad - no transaction, can leave inconsistent state
const order = await prisma.order.create({ data: orderData });
await prisma.product.update({
  where: { id: productId },
  data: { stock: { decrement: quantity } },
}); // If this fails, order is created but stock not updated!

6. Pagination

Always paginate large result sets:

// ✅ Good - paginated
query: {
  page: { type: 'number', min: 1, default: 1 },
  limit: { type: 'number', min: 1, max: 100, default: 20 },
},
handler: async (prisma, { query }) => {
  const skip = (query.page - 1) * query.limit;
  return prisma.product.findMany({
    skip,
    take: query.limit,
  });
}

// ❌ Bad - could return millions of records
handler: async (prisma) => {
  return prisma.product.findMany(); // No limit!
}

Troubleshooting

Error: Path must start with /

❌ Invalid Query Path
Query 'myQuery' has invalid path: api/users

Solution: All paths must start with a forward slash:

path: '/api/users'  // ✅ Correct
path: 'api/users'   // ❌ Wrong

Error: Undeclared path parameter

❌ Undeclared Path Parameter
Query 'getOrder' uses path parameter ':userId' but it's not declared

Solution: Declare all path parameters:

path: '/users/:userId/orders',
params: {
  userId: { type: 'number', required: true },  // ✅ Declared
}

Error: Invalid parameter type

❌ Invalid Parameter Type
Parameter 'id' has invalid type: integer

Solution: Use valid types: string, number, boolean, array:

params: {
  id: { type: 'number' },  // ✅ Correct
  id: { type: 'integer' }, // ❌ Wrong
}

Error: enum can only be used with string type

❌ Invalid Parameter Enum
Parameter 'status' uses enum with non-string type

Solution: Only use enum with string type:

query: {
  status: {
    type: 'string',  // ✅ Must be string
    enum: ['active', 'inactive'],
  },
}

Error: min/max can only be used with number type

❌ Invalid Parameter Range
Parameter 'name' uses min/max with non-number type

Solution: Only use min/max with number type:

query: {
  age: {
    type: 'number',  // ✅ Must be number
    min: 0,
    max: 120,
  },
}

Next Steps


Need help? Open an issue on GitHub

Was this page helpful?