Query definitions

Transform your hypequery metrics into HTTP endpoints using initServe. This creates a type-safe, self-documenting API layer that can be consumed via HTTP, executed directly, or called from AI agents.

New to Serve? Start with the Serve overview for deployment models, then dive into the Serve reference for every API surface.

Basic structure

A query definition wraps your metric logic with metadata, validation, and routing:

import { initServe } from '@hypequery/serve';
import { z } from 'zod';
import { db } from './client';

const { define, queries } = initServe({
  context: () => ({ db }),
});

export const api = define({
  queries: queries({
    weeklyRevenue: {
      name: 'Weekly revenue',
      query: async ({ ctx, input }) => {
        return ctx.db
          .table('transactions')
          .where('date', '>=', input.startDate)
          .where('date', '<=', input.endDate)
          .sum('amount');
      },
      inputSchema: z.object({
        startDate: z.string(),
        endDate: z.string(),
      }),
      outputSchema: z.object({
        total: z.number(),
      }),
      summary: 'Get weekly revenue totals',
      description: 'Returns sum of all transactions for a date range',
      tags: ['revenue', 'analytics'],
    },
  }),
});

// Register HTTP route
api.route('/metrics/weekly-revenue', api.queries.weeklyRevenue);

Query configuration

Each query accepts the following options:

query (required)

The core function that executes your metric logic:

query: async ({ input, ctx }) => {
  // input: validated request payload
  // ctx: request context (auth, tenantId, locals, etc.)

  return ctx.db.table('orders')
    .where('created_at', '>=', input.startDate)
    .select('*')
    .execute();
}

inputSchema

Zod schema for request validation:

inputSchema: z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
  status: z.enum(['pending', 'completed']).optional(),
})

outputSchema

Zod schema for response typing and documentation:

outputSchema: z.object({
  total: z.number(),
  currency: z.string(),
  breakdown: z.array(z.object({
    date: z.string(),
    amount: z.number(),
  })),
})

method

HTTP method for this query. Defaults to GET. You can set it on the definition or override it when registering the route:

// Inline on the query
method: 'POST'

// Or override later if exposing the route
api.route('/revenue', api.queries.revenue, { method: 'POST' });

Pick one style per endpoint—if you specify both, the value passed to api.route takes priority.

name

Human-friendly display name for docs, OpenAPI, and api.describe() consumers. Defaults to the query key. Use this when the key is terse but you want richer presentation:

name: 'Weekly revenue (USD)'

summary

Short description for OpenAPI documentation:

summary: 'Get weekly revenue totals'

description

Detailed description for documentation:

description: 'Returns the sum of all transactions within a given date range, grouped by day'

tags

Tags for grouping in OpenAPI/documentation:

tags: ['revenue', 'analytics', 'financial']

middlewares

Endpoint-specific middleware:

middlewares: [
  async (ctx, next) => {
    console.log('Before query execution');
    const result = await next();
    console.log('After query execution');
    return result;
  },
]

auth

Endpoint-specific authentication:

auth: async ({ request }) => {
  const token = request.headers['x-api-key'];
  if (token === 'secret') {
    return { userId: '123', role: 'admin' };
  }
  return null;
}

tenant

Tenant isolation configuration (see Multi-tenancy isolation):

tenant: {
  extract: (auth) => auth.organizationId,
  required: true,
}

cacheTtlMs

Sets the HTTP Cache-Control header that will be applied once you register this query as an HTTP route. This does not cache ClickHouse queries—it only tells downstream clients/CDNs how long they may reuse the HTTP response. Use the query builder’s cache helpers if you want to memoize database results server-side.

cacheTtlMs: 60_000 // Cache for 1 minute

Need to override it dynamically? Call ctx.setCacheTtl(ms) inside the handler to adjust the header per request (or pass null to force Cache-Control: no-store).

custom

Custom metadata (see Custom metadata):

custom: {
  owner: 'data-team',
  sla: '100ms',
  criticality: 'high',
}

Builder pattern

initServe also returns a query builder that creates endpoint definitions via a fluent chain. This is useful when you want to compose auth guards, middleware, and metadata without nesting config objects:

import { initServe } from '@hypequery/serve';
import { z } from 'zod';
import { db } from './client';

const { define, query } = initServe({
  context: () => ({ db }),
});

const api = define({
  queries: {
    // Public endpoint — no auth even when global auth is configured
    healthcheck: query
      .public()
      .query(async () => ({ status: 'ok' })),

    // Requires authentication
    metrics: query
      .requireAuth()
      .describe('General metrics overview')
      .input(z.object({ range: z.string() }))
      .query(async ({ ctx, input }) =>
        ctx.db.table('metrics').where('range', 'eq', input.range).select('*'),
      ),

    // Requires admin role
    adminDashboard: query
      .requireRole('admin')
      .tag('admin')
      .query(async ({ ctx }) =>
        ctx.db.table('admin_metrics').select('*'),
      ),

    // Requires specific scopes
    exportData: query
      .requireScope('read:metrics', 'export:data')
      .method('POST')
      .input(z.object({ format: z.enum(['csv', 'json']) }))
      .query(async ({ ctx, input }) => { /* ... */ }),
  },
});

The builder and config-object styles produce identical ServeQueryConfig values—pick whichever reads better for your team. See Authentication for the full auth guards reference.

Reusing query types

Need fully typed inputs/outputs elsewhere (React hooks, API routes, SDKs)? Use the helper types exported from @hypequery/serve:

import type { InferQueryInput, InferQueryOutput, InferQueryResult } from '@hypequery/serve';
import type { api } from './analytics/api';

type TripsInput = InferQueryInput<typeof api, 'tripsQuery'>;        // input schema
type TripsResult = InferQueryResult<typeof api, 'tripsQuery'>;     // builder return type
type TripsResponse = InferQueryOutput<typeof api, 'tripsQuery'>;   // zod-derived type if provided

Use InferQueryResult when you trust the builder’s static typing (no schema required). InferQueryOutput reads the optional outputSchema, which is handy when runtime validation is the source of truth. Both helpers accept either the serve.define instance or a raw ServeQueriesMap, so you can infer types from any subset of queries.

Inline execution helpers

Calling await api.run('tripsQuery') (alias of api.execute) now returns the same type as InferQueryResult<typeof api, 'tripsQuery'>. For inline scripts or devtools, you can lean on the helper to annotate external call sites:

import { api } from './analytics/api';
import type { InferQueryResult } from '@hypequery/serve';

type Trips = InferQueryResult<typeof api, 'tripsQuery'>;

export async function listTrips(): Promise<Trips> {
  return api.run('tripsQuery');
}

If your query returns a builder directly, make sure to .execute() before returning so the type collapses to the parsed result instead of the intermediate QueryBuilder.

Organizing queries

Single file approach

For small projects, define all queries in one file:

// api/index.ts
import { initServe } from '@hypequery/serve';
import { db } from '../analytics/client';

const { define, queries } = initServe({
  context: () => ({ db }),
});

export const api = define({
  queries: queries({
    revenue: { /* ... */ },
    users: { /* ... */ },
    orders: { /* ... */ },
  }),
});

api.route('/metrics/revenue', api.queries.revenue);
api.route('/metrics/users', api.queries.users);
api.route('/metrics/orders', api.queries.orders);

Module-based approach

For larger projects, split by domain:

// metrics/revenue.ts
export const revenueQueries = {
  weeklyRevenue: {
    query: async ({ input }) => { /* ... */ },
    inputSchema: z.object({ /* ... */ }),
  },
  monthlyRevenue: {
    query: async ({ input }) => { /* ... */ },
  },
};

// metrics/users.ts
export const userQueries = {
  activeUsers: {
    query: async ({ input }) => { /* ... */ },
  },
};

// api/index.ts
import { initServe } from '@hypequery/serve';
import { db } from '../analytics/client';
import { revenueQueries } from './metrics/revenue';
import { userQueries } from './metrics/users';

const { define, queries } = initServe({
  context: () => ({ db }),
});

export const api = define({
  queries: queries({
    ...revenueQueries,
    ...userQueries,
  }),
});

// Auto-register all routes
Object.entries(api.queries).forEach(([key, query]) => {
  const path = `/metrics/${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
  api.route(path, query);
});

Factory pattern

For queries with shared logic:

// lib/query-factory.ts
import { z } from 'zod';

export const createDateRangeQuery = (table: string, sumColumn: string) => ({
  query: async ({ ctx, input }) => {
    return ctx.db
      .table(table)
      .where('date', '>=', input.startDate)
      .where('date', '<=', input.endDate)
      .sum(sumColumn);
  },
  inputSchema: z.object({
    startDate: z.string(),
    endDate: z.string(),
  }),
  outputSchema: z.object({
    total: z.number(),
  }),
});

// Use the factory
import { initServe } from '@hypequery/serve';
import { db } from '../analytics/client';

const { define, queries } = initServe({
  context: () => ({ db }),
});

const api = define({
  queries: queries({
    revenue: createDateRangeQuery('transactions', 'amount'),
    refunds: createDateRangeQuery('refunds', 'amount'),
  }),
});

Execution modes

HTTP execution

// Deploy as HTTP server
api.start({ port: 3000 });

// Or use as middleware in Next.js, Express, etc.
export default api.handler;

Direct execution

// Execute without HTTP layer
const result = await api.run('weeklyRevenue', {
  input: {
    startDate: '2025-01-01',
    endDate: '2025-01-07',
  },
});

AI agent integration

// Expose to AI agents via MCP
const description = api.describe();

description.queries.forEach(query => {
  console.log({
    name: query.key,
    description: query.summary,
    parameters: query.inputSchema,
    output: query.outputSchema,
  });
});

Global configuration

Apply settings to all queries:

import { initServe } from '@hypequery/serve';

const { define, queries } = initServe({
  basePath: '/api/v1',

  // Global auth
  auth: async ({ request }) => {
    return verifyToken(request.headers['authorization']);
  },

  // Global tenant isolation
  tenant: {
    extract: (auth) => auth.tenantId,
    required: true,
  },

  // Global middleware
  middlewares: [
    async (ctx, next) => {
      const start = Date.now();
      const result = await next();
      console.log(`${ctx.metadata.path} took ${Date.now() - start}ms`);
      return result;
    },
  ],

  // Global context factory
  context: async ({ request, auth }) => ({
    db: createDbConnection(),
    logger: createLogger({ userId: auth?.userId }),
  }),

  // Lifecycle hooks
  hooks: {
    onRequestStart: async (event) => {
      console.log(`Request started: ${event.queryKey}`);
    },
    onRequestEnd: async (event) => {
      console.log(`Request completed in ${event.durationMs}ms`);
    },
    onError: async (event) => {
      console.error(`Error in ${event.queryKey}:`, event.error);
    },
  },
});

const api = define({
  queries: queries({
    // Your queries inherit all global config
  }),
});

Best practices

1. Use descriptive query keys

// ✅ Good - clear and specific
queries: {
  weeklyRevenue: { /* ... */ },
  activeUsersByRegion: { /* ... */ },
  topSellingProducts: { /* ... */ },
}

// ❌ Bad - vague
queries: {
  query1: { /* ... */ },
  getData: { /* ... */ },
  fetch: { /* ... */ },
}

2. Always provide schemas for public APIs

// ✅ Good - validated and documented
queries: {
  revenue: {
    query: async ({ input }) => { /* ... */ },
    inputSchema: z.object({ startDate: z.string(), endDate: z.string() }),
    outputSchema: z.object({ total: z.number() }),
  },
}

// ⚠️ Acceptable for internal use only
queries: {
  internalMetric: {
    query: async () => { /* ... */ },
    // No schemas - type safety via execute() only
  },
}

3. Include name, summary, and tags

queries: {
  revenue: {
    name: 'Revenue (date range)',
    query: async ({ input }) => { /* ... */ },
    summary: 'Get revenue totals for date range',
    description: 'Calculates sum of all completed transactions',
    tags: ['revenue', 'financial', 'analytics'],
  },
}

4. Leverage shared configuration

// Don't repeat yourself - use global config
const { define, queries } = initServe({
  auth: myGlobalAuth,
  tenant: myGlobalTenant,
  middlewares: [loggingMiddleware],
});

const api = define({
  queries: queries({
    // All inherit global config
    query1: { /* ... */ },
    query2: { /* ... */ },

    // Override when needed
    adminQuery: {
      query: { /* ... */ },
      auth: adminOnlyAuth, // Override
    },
  }),
});

Next steps