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 minuteNeed 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 providedUse 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
- Learn about Input/output schemas for validation
- Add Custom metadata for governance
- Configure Multi-tenancy isolation
- Explore HTTP + OpenAPI delivery