# hypequery — full documentation > Concatenated Markdown for every docs page. See /llms.txt for the indexed list. # Authentication (/docs/authentication) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Authentication [#authentication] Authentication in hypequery starts at the runtime layer. You attach auth strategies to `initServe(...)` or `serve({ ... })`, and the resolved auth object is made available as `ctx.auth` inside your query definitions. Choose a strategy [#choose-a-strategy] Most apps don't need to hand-roll credential parsing. Pick the built-in strategy that matches how your runtime is deployed: | Deployment | Strategy | Use when | | ----------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------- | | Same app | `fromContext(...)` | hypequery runs inside an app that already authenticates the request | | Cross-origin / embedded | `createJwtStrategy(...)` | a separate client sends a JWT (your own HS256 secret or a provider's JWKS) | | Signed embedding | `createAnalyticsTokenIssuer(...)` | you mint short-lived analytics tokens server-side | | Custom systems | `createApiKeyStrategy(...)` / `createBearerTokenStrategy(...)` | you need bespoke credential handling | Whichever you choose, the resolved auth object is exposed as `ctx.auth`. When `auth` is configured, endpoints require authentication by default — mark exceptions with `requiresAuth: false` (or `query.public()`). Same-app auth with fromContext (recommended) [#same-app-auth-with-fromcontext-recommended] If hypequery runs inside an app that already authenticates requests, reuse that session instead of validating credentials again. `fromContext` hands you the request so you can read the user your framework already resolved. ```typescript import { fromContext, initServe } from '@hypequery/serve'; import { db } from './client'; // Your app's existing session helper — reads the cookie/token and returns the user. import { getUserFromRequest } from './auth'; const { query, serve } = initServe({ context: () => ({ db }), auth: fromContext(({ request }) => { const user = getUserFromRequest(request.raw); return user ? { userId: user.id, tenantId: user.orgId, roles: user.roles } : null; }), basePath: '/api/analytics', }); const tenantUsers = query({ requiresAuth: true, query: ({ ctx }) => ctx.db .table('users') .select(['id', 'email', 'last_seen_at']) .where('tenant_id', 'eq', ctx.auth!.tenantId) .orderBy('last_seen_at', 'DESC') .limit(50) .execute(), }); export const api = serve({ queries: { tenantUsers }, }); ``` `request.raw` is the underlying framework request (the Node/Fetch object), so you can call whatever session helper you already use. Cross-origin auth with createJwtStrategy [#cross-origin-auth-with-createjwtstrategy] When a separate client calls your runtime, verify a JWT bearer token. Use a shared `secret` for tokens you mint yourself (HS256), or `jwksUri` for tokens from a provider like Auth0, Clerk, or Cognito (RS256). ```typescript import { createJwtStrategy, initServe } from '@hypequery/serve'; import { db } from './client'; // Tokens you mint yourself (HS256). const secretAuth = createJwtStrategy({ secret: process.env.HYPEQUERY_AUTH_SECRET!, issuer: 'https://your-app.example.com', audience: 'hypequery-analytics', }); // Tokens from a provider via JWKS (RS256). const providerAuth = createJwtStrategy({ jwksUri: 'https://example.auth0.com/.well-known/jwks.json', issuer: 'https://example.auth0.com/', audience: 'https://api.example.com', }); const { query, serve } = initServe({ context: () => ({ db }), auth: secretAuth, }); ``` By default the verified claims are mapped to `ctx.auth` as `sub → userId`, `org_id → tenantId`, `roles → roles`, and `scope`/`scopes → scopes`. Override that with `mapClaims(payload, request)` when your tokens use different claim names. Signed embedding with createAnalyticsTokenIssuer [#signed-embedding-with-createanalyticstokenissuer] For embedded dashboards, mint short-lived analytics tokens on your server and verify them with `createJwtStrategy({ secret })`. ```typescript import { createAnalyticsTokenIssuer } from '@hypequery/serve'; const issueAnalyticsToken = createAnalyticsTokenIssuer({ secret: process.env.HYPEQUERY_AUTH_SECRET!, expiresIn: '15m', issuer: 'https://your-app.example.com', audience: 'hypequery-analytics', }); // In an authenticated route on your own server: app.get('/api/analytics/token', requireUser, async (req, res) => { res.json({ token: await issueAnalyticsToken({ userId: req.user.id, tenantId: req.user.orgId, roles: req.user.roles, }), }); }); ``` Custom strategies [#custom-strategies] When you need bespoke credential handling, `createApiKeyStrategy` and `createBearerTokenStrategy` give you a `validate` hook that returns your auth object or `null`. API key [#api-key] ```typescript import { createApiKeyStrategy, initServe } from '@hypequery/serve'; import { db } from './client'; const apiKeyAuth = createApiKeyStrategy({ header: 'x-api-key', validate: async (key) => { const account = await findApiKey(key); if (!account) return null; return { userId: account.userId, tenantId: account.tenantId, role: account.role, }; }, }); const { query, serve } = initServe({ context: () => ({ db }), auth: apiKeyAuth, basePath: '/api/analytics', }); ``` Bearer token [#bearer-token] ```typescript import { createBearerTokenStrategy, initServe } from '@hypequery/serve'; import { db } from './client'; const bearerAuth = createBearerTokenStrategy({ validate: async (token) => { const payload = await verifyJwt(token); return payload ? { userId: payload.sub, email: payload.email, tenantId: payload.tenantId, } : null; }, }); const { query, serve } = initServe({ context: () => ({ db }), auth: bearerAuth, }); ``` Prefer `createJwtStrategy` over a hand-written bearer `validate` when you're verifying standard JWTs — it handles signature verification, issuer/audience checks, and claim mapping for you. Where auth lives [#where-auth-lives] * attach auth globally in `initServe(...)` or `serve({ ... })` * read the resolved auth object from `ctx.auth` * combine auth with [Multi-Tenancy](/docs/multi-tenancy) when tenant identity comes from credentials Per-query auth in query({ ... }) [#per-query-auth-in-query--] Use object-style auth fields when a reusable query definition should enforce access rules directly. ```typescript import { createAuthSystem, initServe } from '@hypequery/serve'; import { db } from './client'; const { useAuth, TypedAuth } = createAuthSystem({ roles: ['admin', 'editor'] as const, scopes: ['read:data', 'write:data'] as const, }); type AppAuth = typeof TypedAuth; const authStrategy = async ({ request }): Promise => { const token = request.headers['x-auth-token']; if (!token) return null; const payload = await verifyJwt(token); return { userId: payload.sub, roles: payload.roles, scopes: payload.scopes, }; }; const { query, serve } = initServe({ context: () => ({ db }), auth: useAuth(authStrategy), }); const adminMetrics = query({ description: 'Admin-only revenue metrics', requiredRoles: ['admin'], requiredScopes: ['read:data'], query: async ({ ctx }) => ctx.db .table('metrics') .select(['name', 'value', 'updated_at']) .orderBy('updated_at', 'DESC') .limit(20) .execute(), }); const health = query({ requiresAuth: false, query: async () => ({ ok: true }), }); export const api = serve({ queries: { adminMetrics, health }, }); ``` Semantics: * `requiresAuth: false` makes a query public * `requiresAuth: true` requires an authenticated user * `requiredRoles: ['admin', 'editor']` uses OR semantics * `requiredScopes: ['read:data', 'write:data']` uses AND semantics * `requiredRoles` or `requiredScopes` imply auth automatically Typed authorization with createAuthSystem [#typed-authorization-with-createauthsystem] Use `createAuthSystem(...)` when you want compile-time safety for roles and scopes. ```typescript import { createAuthSystem, initServe } from '@hypequery/serve'; const { useAuth, TypedAuth } = createAuthSystem({ roles: ['admin', 'editor'] as const, scopes: ['read:data', 'write:data'] as const, }); type AppAuth = typeof TypedAuth; const authStrategy = async ({ request }): Promise => { const token = request.headers['x-auth-token']; if (!token) return null; const payload = await verifyJwt(token); return { userId: payload.sub, roles: payload.roles, scopes: payload.scopes, }; }; const { query, serve } = initServe({ context: () => ({ db }), auth: useAuth(authStrategy), }); const adminMetrics = query({ requiredRoles: ['admin'], query: async ({ ctx }) => ctx.db .table('metrics') .select(['name', 'value']) .orderBy('value', 'DESC') .limit(10) .execute(), }); ``` This gives you: * autocomplete for valid roles and scopes * compile-time checking for `requiredRoles` and `requiredScopes` * a typed `ctx.auth` shape across auth strategies, queries, and middleware Notes [#notes] Auth strategies receive a ServeRequest whose headers are plain objects, not Fetch Headers. Use request.headers.authorization or request.headers\['x-api-key']. Guard methods [#guard-methods] The query builder-compatible auth guards are still current and supported: * `.requireAuth()` * `.requireRole(...)` * `.requireScope(...)` * `.public()` Use object-style auth fields by default on `query({ ... })`. Use the chainable guard methods when you prefer the builder-style query surface or need backwards compatibility with existing guard-heavy definitions. Auth on semantic endpoints [#auth-on-semantic-endpoints] Auto-generated `metrics` and `datasets` endpoints are protected the same way as queries, but you declare the requirements on the per-entry config object instead of inside a `query({ ... })` definition. Each entry accepts `auth`, `requiresAuth`, `requiredRoles`, and `requiredScopes`. ```typescript import { initServe } from '@hypequery/serve'; import { db } from './client'; import { Orders, revenue } from './datasets/orders'; const { serve } = initServe({ context: () => ({ db }), auth: authStrategy, }); export const api = serve({ queryBuilder: db, metrics: { // Shorthand: inherits the global auth strategy with no extra requirements. revenue, }, datasets: { orders: { dataset: Orders, requiredRoles: ['analytics'], requiredScopes: ['read:data'], }, }, }); ``` The semantics match `query({ ... })`: * `requiredRoles` uses OR semantics (any listed role grants access) * `requiredScopes` uses AND semantics (all listed scopes required) * declaring either one implies authentication * `auth` on an entry adds a local strategy; omitting it or setting `auth: null` still inherits global auth * `requiresAuth: false` makes an entry public unless it declares required roles or scopes * `requiresAuth: true` requires authentication even when no local or global strategy is configured If you previously used `auth: null` as a public override on a dataset or metric, replace it with `requiresAuth: false` when upgrading. Metrics use the same shape via `{ metric, auth, requiresAuth, requiredRoles, requiredScopes }`. See [Serve integration](/docs/datasets/serve-integration) for the full set of per-entry options. Trusted principals for in-process hosts [#trusted-principals-for-in-process-hosts] Some hosts verify the caller before Serve ever sees the request — a Cloud gateway that checks its own credential, or a worker running a deployment bundle. Those hosts already hold a verified principal and have no auth strategy of their own to re-run. `api.execute()` (and its `client()` / `run()` aliases) accept a `trustedAuth` option for exactly that case: ```typescript // Only inside a host that has already verified the caller itself. const rows = await api.execute('revenue', { input: { month: '2026-07' }, trustedAuth: { userId: verified.sub, tenantId: verified.org, roles: verified.roles, scopes: verified.scopes, }, }); ``` Supplying `trustedAuth` skips **only** credential parsing — the configured auth strategies do not run. Everything else still applies to the principal you pass: * `requiredRoles` and `requiredScopes` are enforced, so an under-privileged principal gets a `FORBIDDEN` error just as an HTTP caller would * `tenant.extract` runs against it, and a required tenant that cannot be extracted is rejected * the context factory receives it as `auth`, and input/output validation, middleware, and lifecycle hooks are unchanged * responses stay `cache-control: no-store` `trustedAuth` is an assertion that *your host* authenticated the caller. Never populate it from request headers, a request body, a query string, or any other value the caller controls — doing so lets a client name its own principal. It is unreachable from the HTTP handler by design; only in-process callers can set it. Pass `null` or omit it to fall through to the configured strategies. Because the principal is what authorization ran against, the pipeline owns `ctx.auth` and `ctx.tenantId`. A caller-supplied `context` that tries to set either is rejected with a `VALIDATION_ERROR` rather than silently overwriting them: ```typescript // Rejected: `context` may not shadow the authenticated principal. await api.execute('revenue', { trustedAuth: principal, context: { auth: { userId: 'someone-else' } }, }); ``` Deployment runtime artifacts built by `hypequery deployment` forward `trustedAuth` through to `api.execute()`, so a worker hosting a bundle enforces the API's declared permissions and tenancy without reinterpreting its own gateway credential as customer auth. See Also [#see-also] * [Serve Runtime Reference](/docs/reference/api/runtime) * [Query Definition Reference](/docs/reference/api/query) * [Embedded Runtime](/docs/embedded-runtime) --- # What hypequery supports today (/docs/capabilities) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; What hypequery supports today [#what-hypequery-supports-today] This page records the shipped surface of hypequery as of **21 August 2026**. It is a capability matrix, not a roadmap: “native” means a public typed method exists, “expression” means the feature is supported through an explicit SQL expression inside an otherwise typed query, and “semantic” means it can be declared and executed through `@hypequery/datasets`. `argMax`, `argMin`, percentiles, median, standard deviation, variance, `final()`, `limitBy()`, array joins, `PREWHERE`, CTEs, and window expressions are supported today. The tables below show the exact public syntax. ClickHouse query builder [#clickhouse-query-builder] | Capability | Status | Public TypeScript syntax | | -------------------------------- | ---------- | --------------------------------------------------------------------------------------------------- | | Typed table and column selection | Native | `table()`, `select()`, `selectConst()` | | Filters | Native | `where()`, `orWhere()`, grouped predicates, null, range, list, tuple, and pattern operators | | `PREWHERE` | Native | `prewhere()`, `orPrewhere()` and null helpers | | Joins | Native | inner, left, right, full, and `leftAnyJoin()` | | Grouping and `HAVING` | Native | `groupBy()`, `groupByTimeInterval()`, `having()` | | Ordering and pagination | Native | `orderBy()`, `limit()`, `offset()` | | ClickHouse `LIMIT BY` | Native | `limitBy(count, field)` or multiple fields | | `ReplacingMergeTree` `FINAL` | Native | `final()` | | Array expansion | Native | `arrayJoin()`, `leftArrayJoin()` | | Totals and distinct results | Native | `withTotals()`, `distinct()` | | CTEs | Native | `withCTE()` with a builder or SQL body; the alias is a typed join target when its columns are known | | Subqueries in `FROM` | Native | `from(queryBuilder)` with the nested output as the outer typed column scope | | ClickHouse settings | Native | `settings()` | | Streaming | Native | `stream()`, `streamForEach()` | | Query SQL inspection | Native | `toSQL()`, `toSQLWithParams()` | | Result caching | Native | `cache()` and cache providers | | Window functions | Expression | `selectExpr('... OVER (...)', 'alias')` | FINAL [#final] ```ts const currentRows = await db .table('orders') .final() .select(['id', 'status', 'updated_at']) .execute(); ``` LIMIT BY [#limit-by] ```ts const topThreePerRegion = await db .table('orders') .select(['region', 'id', 'amount']) .orderBy('amount', 'DESC') .limitBy(3, 'region') .execute(); ``` Multiple grouping fields are accepted: ```ts .limitBy(5, ['tenant_id', 'category']) ``` Window functions [#window-functions] Window functions use the public `selectExpr` escape hatch. The selected alias remains part of the inferred result type, while the expression body is trusted model code: ```ts import { selectExpr } from '@hypequery/clickhouse'; const ranked = await db .table('orders') .select([ 'region', 'amount', selectExpr( 'row_number() OVER (PARTITION BY region ORDER BY amount DESC)', 'rank', ), ]) .execute(); ``` This same pattern covers `row_number`, `rank`, `dense_rank`, `lag`, `lead`, running totals, moving averages, and custom window frames. Query-builder aggregate surface [#query-builder-aggregate-surface] | Method | ClickHouse output | | --------------------------------- | ------------------------------------------- | | `sum(column, alias?)` | `sum(column)` | | `count(column, alias?)` | `count(column)` | | `countDistinct(column, alias?)` | distinct count | | `avg(column, alias?)` | `avg(column)` | | `min(column, alias?)` | `min(column)` | | `max(column, alias?)` | `max(column)` | | `quantile(column, level, alias?)` | `quantile(level)(column)` | | `argMax(column, by, alias?)` | `argMax(column, by)` | | `argMin(column, by, alias?)` | `argMin(column, by)` | | `stddev(column, alias?)` | sample standard deviation with `stddevSamp` | | `variance(column, alias?)` | sample variance with `varSamp` | ```ts const statistics = await db .table('orders') .select(['region']) .sum('amount', 'revenue') .countDistinct('customer_id', 'unique_customers') .quantile('amount', 0.95, 'p95_order_value') .argMax('status', 'created_at', 'latest_status') .stddev('amount', 'amount_stddev') .variance('amount', 'amount_variance') .groupBy('region') .execute(); ``` Semantic datasets and metrics [#semantic-datasets-and-metrics] `@hypequery/datasets` supports direct execution through `createDatasetClient`, HTTP execution through `@hypequery/serve`, typed React consumers, generated tool schemas, and MCP. | Semantic capability | Status | | ----------------------------------------------------------------- | ------- | | String, number, boolean, and timestamp dimensions | Shipped | | Column aliases and trusted SQL-backed dimensions | Shipped | | Named measures and base metrics | Shipped | | Derived metric formulas | Shipped | | Measure-level filters | Shipped | | Day, week, month, quarter, and year grains | Shipped | | Runtime tenant isolation with fail-closed scope | Shipped | | Dataset queries with selected dimensions and measures | Shipped | | Metric queries with dimensions, filters, order, limit, and offset | Shipped | | One-hop `belongsTo` / `hasOne` dimension traversal | Shipped | | Semantic catalogs and stable contracts | Shipped | | OpenAI, AI SDK, and MCP tool schema generation | Shipped | | Semantic result caching and pagination metadata | Shipped | The semantic aggregate helpers mirror the builder surface: ```ts measure.sum('amount') measure.count('id') measure.countDistinct('customerId') measure.avg('amount') measure.min('amount') measure.max('amount') measure.percentile('amount', 0.95) measure.median('amount') measure.argMax('status', 'createdAt') measure.argMin('status', 'createdAt') measure.stddev('amount') measure.variance('amount') ``` The low-level query builder calls ClickHouse’s aggregate `quantile()`. The semantic layer uses the product-facing name `percentile()` and compiles it to `quantile(level)` for ClickHouse. `median()` is percentile `0.5`. Serve, React, and MCP [#serve-react-and-mcp] | Surface | Shipped capabilities | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@hypequery/serve` | In-process execution, HTTP routes, zod validation, OpenAPI, auth, roles/scopes, tenant injection, CORS, rate limiting, observability, Node and Fetch adapters | | `@hypequery/react` | Typed named-query hooks, metric hooks, dataset hooks, mutations, infinite queries, route manifests, per-request auth headers, one-time 401 refresh | | `@hypequery/mcp` | Dataset discovery, schema introspection, named metric queries, ad hoc dataset queries, generated JSON schemas, host-controlled tenant scope, stdio transport | A complete current dataset [#a-complete-current-dataset] ```ts import { dataset, dimension, divide, measure, nullIfZero, } from '@hypequery/datasets'; export const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { region: dimension.string(), status: dimension.string(), customerId: dimension.string({ column: 'customer_id' }), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), orderCount: measure.count('id'), p95OrderValue: measure.percentile('amount', 0.95), latestStatus: measure.argMax('status', 'createdAt'), }, }); export const revenue = Orders.metric('revenue', { measure: 'revenue' }); export const orderCount = Orders.metric('orderCount', { measure: 'orderCount', }); export const averageOrderValue = Orders.metric('averageOrderValue', { uses: { revenue, orderCount }, formula: ({ revenue, orderCount }) => divide(revenue, nullIfZero(orderCount)), }); ``` Keep this page current [#keep-this-page-current] When a public capability is added or renamed, update this matrix in the same pull request as the implementation and package README. Use the [changelog](/docs/changelog) for release-by-release history and the [roadmap](/docs/roadmap) for work that has not shipped. --- # Changelog (/docs/changelog) import { Callout } from 'fumadocs-ui/components/callout'; Latest updates and improvements across hypequery packages and docs. [2.0.0] [#200] Query Builder Internals and Relationship Semantics [#query-builder-internals-and-relationship-semantics] This release refactors the ClickHouse query builder around a more explicit internal query-node model. Most users will not need to change how they write queries, but the builder is now easier to reason about internally and several advanced behaviors are stricter and better defined. Breaking changes [#breaking-changes] * **Treat builder chains as immutable.** Query-builder methods return a new builder state. If you build queries conditionally, reassign the builder instead of assuming methods mutate the existing instance. ```typescript // Good let query = db.table('users'); if (onlyActive) query = query.where('status', 'eq', 'active'); if (limit) query = query.limit(limit); // Fragile const query = db.table('users'); if (onlyActive) query.where('status', 'eq', 'active'); if (limit) query.limit(limit); ``` * **`withRelation()` is stricter for chained relationships.** Alias override is now only supported for single-step relationships. If you use a relationship chain, define aliases on the chain steps themselves instead of trying to override the whole chain at call time. * **Tuple `IN` filters now validate width more strictly.** Malformed tuples and mismatched tuple widths fail earlier with clearer errors instead of flowing deeper into query compilation. What changed [#what-changed] * **The query builder now compiles from a structured query node instead of relying on looser internal config mutation.** Filtering, joins, ordering, grouping, `HAVING`, CTEs, and settings all move through a more explicit internal model before being compiled to SQL. * **Internal query-builder responsibilities are split into smaller helpers.** Filter application, relation application, relation validation, tuple validation, and config compatibility are now handled in focused internal modules instead of being packed into one large builder implementation. * **`getQueryNode()` is now the clearest inspection API for advanced builder debugging.** It reflects the newer structured internal model directly. `getConfig()` still exists for compatibility, but it should be treated as a legacy inspection helper. * **Advanced `IN` handling is stricter and better covered.** This includes explicit tuple-width validation, better single-column tuple handling, and earlier failures when tuple input does not match the selected columns. * **`isNull` and `isNotNull` are now supported as first-class filter operators.** This makes null checks easier to express without falling back to raw SQL or overloading equality semantics. * **`withRelation()` behavior is now more explicit.** Runtime string lookup still works for registered relationships, but direct `JoinPath` usage is the typed path when you want compile-time table or alias widening. * **Alias override is now limited to single-step relationships.** Chained relationships no longer allow alias override, which avoids ambiguous or misleading builder state in more complex joins. * **The join relationships docs now describe the two `withRelation()` modes more clearly.** The docs now call out when string lookup is fine, when direct join paths are the better typed option, and where alias limits apply. Why it matters [#why-it-matters] * The builder is easier to maintain, extend, and test without changing the main public query-building workflow. * Advanced filters and relationship joins now fail earlier with clearer errors instead of leaving more room for confusing runtime behavior. * Users who rely on builder inspection now have a more accurate mental model of how a query is represented before SQL compilation. Migration notes [#migration-notes] * If you compose queries conditionally, always reassign the builder returned by each call. * Do not treat `getConfig()` or `getQueryNode()` snapshots as mutable builder state. * If you use `withRelation()` with chained relationships plus alias override, expect stricter behavior. Alias override is now only supported for single-step relationships. * If you want typed table or alias widening from `withRelation()`, prefer passing a direct `JoinPath` instead of a string registry key. * If you use tuple `IN` filters, malformed tuple shapes now fail earlier and more explicitly. Connection Config Now Prefers url [#connection-config-now-prefers-url] This release updates docs, examples, and scaffolding to prefer `url` for ClickHouse connections. This matches the direction of `clickhouse-js`, while keeping `host` available as a backward-compatible deprecated option. What changed [#what-changed-1] * **Docs and examples now use `url` as the default connection field.** * **`host` remains supported in the public config types, but is deprecated.** * **Connection namespace derivation now works with either `url` or `host`.** * **CLI scaffolding and env templates now prefer `CLICKHOUSE_URL`, while still accepting legacy `CLICKHOUSE_HOST`.** * **Compatibility coverage was added for host-only configs so existing setups continue to initialize correctly.** Why it matters [#why-it-matters-1] * New code follows the current ClickHouse client direction instead of centering a deprecated field. * Existing users do not need to migrate immediately just to stay on a working release. * Cache and adapter namespace behavior stays stable whether a project still uses `host` or has moved to `url`. Migration notes [#migration-notes-1] * Prefer `url` in new code, examples, and environment variables. * Existing `host`-only configs should continue to work. * If you maintain templates or starter code around hypequery, update them to prefer `CLICKHOUSE_URL`. Grouping, Aggregation, and Filter Correctness Fixes [#grouping-aggregation-and-filter-correctness-fixes] This release also fixes several query-builder correctness issues around grouping, aggregation inference, and empty-set filter behavior. What changed [#what-changed-2] * **Empty exclusion filters now behave correctly.** * `IN []` and `GLOBAL IN []` compile to `1 = 0` * `NOT IN []` and `GLOBAL NOT IN []` compile to `1 = 1` This matches the expected meaning of “exclude nothing”. * **Repeated `groupBy()` calls are now additive.** Additional grouping expressions are appended instead of replacing earlier ones, and repeated expressions are de-duplicated. * **Aggregation inference now handles aliased selected expressions correctly.** If you select an aliased expression and then add an aggregation, the required grouping entry is preserved so the generated SQL stays valid. * **Explicit `groupBy()` clauses are preserved when aggregations are added.** The builder avoids rebuilding grouping state in a way that duplicates entries like `GROUP BY name, name`. * **Aggregation helpers now accept qualified joined columns in their type surface.** Calls like `count('users.id', 'user_count')` and `sum('orders.total', 'total_sales')` now match the runtime SQL support more accurately. Why it matters [#why-it-matters-2] * Aggregation-heavy queries are less likely to produce invalid SQL in edge cases. * Repeated `groupBy()` calls now behave more like the rest of the fluent API. * Joined-column aggregations are easier to express without fighting the type system. New ClickHouse Query Features [#new-clickhouse-query-features] This release adds several ClickHouse-specific builder methods that map directly to useful ClickHouse SQL features: * `arrayJoin()` * `leftArrayJoin()` * `limitBy()` * `withTotals()` What changed [#what-changed-3] * **`arrayJoin()` and `leftArrayJoin()` are now first-class builder methods.** You no longer need to drop to raw SQL for common array expansion patterns. * **`limitBy()` is now supported directly in the fluent builder.** This makes ClickHouse’s per-group row limiting easier to express in typed queries. * **`withTotals()` is now supported on grouped queries.** This exposes ClickHouse `WITH TOTALS` directly from the builder. * **SQL rendering, type coverage, and integration coverage were added for all three features.** Why it matters [#why-it-matters-3] * More ClickHouse-native query patterns can stay inside the typed builder instead of falling back to raw SQL. * Array expansion, top-N-per-group style queries, and grouped totals are easier to write and easier to keep consistent. Follow-up type tightening [#follow-up-type-tightening] * **`arrayJoin()` and `leftArrayJoin()` now only accept array-typed columns at the type level.** Joined and aliased columns still work, but they must resolve to array-valued fields. CLI Hardening and Scaffold Reliability [#cli-hardening-and-scaffold-reliability] This release also hardens the CLI around non-interactive setup, generated scaffold compatibility, and dependency installation. What changed [#what-changed-4] * **`hypequery init` is stricter about non-interactive behavior.** When `--no-interactive` is used, the CLI now stays out of prompt-only code paths and fails cleanly if connection validation fails. * **CLI option normalization is more reliable.** Commander-style `--no-interactive` flows map cleanly onto the CLI’s internal `noInteractive` behavior. * **Generated scaffold imports are now NodeNext-safe.** Generated files use explicit `.js` relative imports where needed, which avoids module-resolution issues in NodeNext projects. * **Scaffold dependency installation is more robust.** The CLI now installs the scaffold dependencies it actually needs, including `zod`, and keeps canary sibling package versions aligned when scaffolding from a canary CLI build. * **Cancellation and overwrite flows behave more cleanly.** The setup path exits earlier and more predictably when users cancel prompts or decline overwrite or continue paths. Why it matters [#why-it-matters-4] * Automated setup is more predictable in CI and other non-interactive environments. * Generated starter projects are less likely to fail immediately on stricter TypeScript and NodeNext setups. * Scaffolded projects are more likely to come up with the right dependencies already installed. Exported Time-Bucketing Helpers [#exported-time-bucketing-helpers] This release exports the built-in ClickHouse start-of-time helpers directly from `@hypequery/clickhouse`. What changed [#what-changed-5] The following helpers are now available from the package entrypoint: * `toStartOfMinute` * `toStartOfHour` * `toStartOfDay` * `toStartOfWeek` * `toStartOfMonth` * `toStartOfQuarter` * `toStartOfYear` These helpers were added to the public export surface with test and integration coverage. Why it matters [#why-it-matters-5] * Common time-bucketing expressions are easier to discover and import directly. * Teams can use the built-in start-of helpers without reaching into internal module paths or re-creating the same expressions with raw SQL. [Upcoming] [#upcoming] Serve and CLI Updates [#serve-and-cli-updates] This release pushes the current object-style hypequery path further forward: `query({ ... })` + `serve({ queries })` is now the clearer default for new integrations, and the underlying serve runtime and CLI scaffolding both support that direction more directly. What changed [#what-changed-6] * **`@hypequery/serve` now supports object-style auth and tenant metadata more completely.** Object-style query definitions can carry runtime metadata such as: * `requiresAuth` * `auth` * `tenant` * `requiredRoles` * `requiredScopes` * `custom` That metadata is preserved when queries are defined with `query({ ... })`, reused through `serve({ queries })`, and surfaced through runtime inspection/endpoint descriptions. * **Object-style auth requirements are enforced through the serve runtime**, including public routes and role/scope-based authorization. This closes a gap between the newer object-style API and the older builder-first flow. * **Object-style tenant overrides now apply through the serve runtime**, so per-query tenant behavior works in the newer API without requiring a fallback to builder-first patterns. * **`@hypequery/cli` now scaffolds the current main path by default.** Generated query templates use: * `initServe(...)` * object-style `query({ ... })` * `serve({ queries })` instead of centering the older builder-first serve style in generated starter code. * **The docs and migration path now line up with the shipped API direction.** The current object-style runtime API is documented as the primary path, while the older builder-first serve docs are preserved under `v0.1.x` for teams that are still migrating. * **Docs search is now better at finding code/API terms** like `dictGet`, `withScalar`, and similar technical keywords that appear primarily in examples and code snippets. * **Query inspection now centers on structured query nodes.** `getQueryNode()` is now the preferred public inspection API for builder state. `getConfig()` still exists for compatibility, but it is deprecated and should be treated as a legacy inspection helper rather than a stable flat config interface. Why it matters [#why-it-matters-6] * New projects start on the current query/serve API instead of scaffolding into a style they immediately need to migrate away from. * Existing users get a clearer migration path from the v0.1.x serve flow to the current object-style runtime API. * The newer query/serve API is now closer to feature parity for auth and tenant concerns, reducing the need to fall back to older patterns. * Builder consumers now have a clearer public inspection path that matches the internal query-node model. [1.5.0] [#150] Breaking Changes [#breaking-changes-1] * **DateTime type mapping correction**: `DateTime`, `DateTime64`, `Date`, and `Date32` columns are now correctly typed as `string` instead of `Date`. This matches the actual runtime behavior of `@clickhouse/client` when using JSON output formats (like `JSONEachRow`), which return DateTime values as strings to preserve sub-millisecond precision that JavaScript `Date` objects cannot represent. **Migration guide:** ```typescript // Before (would fail at runtime): const results = await db.table('products').select(['created_at']).execute(); results[0].created_at.toISOString(); // TypeError: toISOString is not a function // After (correctly typed): const results = await db.table('products').select(['created_at']).execute(); // TypeScript now correctly knows created_at is a string const date = new Date(results[0].created_at); // Convert to Date when needed date.toISOString(); // Works! ``` **Why this change:** Previously, TypeScript indicated these fields were `Date` objects, but at runtime they were actually strings. This caused type mismatches and runtime errors. The fix surfaces this at compile time, preventing bugs. [1.4.0] [#140] Features [#features] * add an experimental caching layer that plugs into every `execute()` call: supports `cache-first`, `network-first`, and `stale-while-revalidate` modes, per-query overrides, tag invalidation, in-flight dedupe, cache-aware logging metadata, and a `CacheController` API for warming or inspecting hit stats. Ships with a memory LRU provider plus serialization helpers and provider hooks for custom stores. * expand the example dashboard with a cache demo page (`/cache`), refresh/invalidate buttons, warming + stats API routes, and environment toggles so developers can see cache hits/misses/stale hits in real time. Fixes / Improvements [#fixes--improvements] * ensure cached entries default `cacheTimeMs` to `ttlMs + staleTtlMs`, fix namespace parsing in the memory provider so tag invalidation works even when the host contains a protocol, and run `mergeCacheOptionsPartial`/`initializeCacheRuntime` helpers to keep query-builder lean. * simplify the example dashboard configuration by removing the Upstash fallback, documenting provider requirements (tag hooks/TTL handling), and adding `/api/cache/*` endpoints to warm caches and inspect hit rates. * make `npm run test` fast again (unit + type tests only) while moving integration tests behind `npm run test:integration`, and streamline `withRelation` so chained relationships reuse a single join applier. [1.3.2] [#132] Features [#features-1] * teach expressions and select clauses their result types, so aliased expressions (e.g. `rawAs`) flow through to the query output and expression helpers know whether they produce booleans, numbers, etc. * refactor the query builder around a single state object: joins now widen the visible-table set (including aliases), predicates/order/group/having all read from that state, and the new `selectConst()` helper locks in literal column inference for downstream clauses. Runtime/type tests and docs were updated to cover alias-aware joins, HAVING-on-alias flows, and `withCTE` pipelines. Because joins now register tables before the select clause is evaluated, builder chains that previously called `.select()` before `.join()` may surface new type errors. Reorder joins ahead of select clauses to resolve the stricter checking without a runtime change. [1.3.0] [#130] Features [#features-2] * add predicate-builder callbacks (with ClickHouse function + logical helpers) to `where`/`orWhere`, enabling predicates like `hasAny(tags, ['foo','bar'])` without raw SQL; columns/arrays are inferred automatically and `expr.raw()` provides an escape hatch for edge cases For historical changes and version history, see the [full CHANGELOG.md](https://github.com/hypequery/hypequery/blob/main/packages/clickhouse/CHANGELOG.md) in the repository. --- # chDB (Embedded ClickHouse) (/docs/chdb) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Getting started [#getting-started] ```typescript import { createQueryBuilder } from '@hypequery/clickhouse'; import { Session } from 'chdb'; import { chdbAdapter } from 'chdb/hypequery'; const session = new Session('./analytics.chdb'); // or new Session() for in-memory const db = createQueryBuilder({ adapter: chdbAdapter({ session }) }); await db.table('trips') .where('passenger_count', 'gte', 2) .select(['passenger_count']) .count('trip_id', 'trip_count') .sum('total_amount', 'revenue') .groupBy(['passenger_count']) .orderBy('passenger_count', 'ASC') .execute(); ``` Nothing else changes: `toSQL()`, `rawQuery()`, and streaming all work, and the adapter renders SQL with hypequery's own exported helpers, so the queries are byte-identical to what the built-in HTTP adapter sends to a ClickHouse server. Why Run Embedded? [#why-run-embedded] * **Testing and CI** — run your hypequery analytics against a real ClickHouse engine with no container and no shared staging environment. The builder code your tests exercise is the code you ship. * **Local development** — build a dashboard against a local Parquet file or an in-memory database, then point the same code at remote ClickHouse for production by swapping the adapter. * **Serverless** — chDB runs in any Node.js environment, including AWS Lambda (note: chDB's native binary is large; verify your deployment package stays within Lambda's 250 MB unzipped limit). Edge runtimes that run in V8 isolates (Cloudflare Workers, Vercel Edge Runtime) cannot load native Node.js addons and are not supported. * **Local files and beyond** — chDB reads Parquet, CSV, S3, and more through ClickHouse table functions, queryable via `rawQuery()`. Zero-Server Scaffolding [#zero-server-scaffolding] `hypequery init` can scaffold a project straight onto the embedded engine — no server, no credentials, and no `.env` created or updated: ```bash npx hypequery init --database chdb ``` You can also run `npx hypequery init` and choose **chDB (embedded, no server)** from the interactive database prompt. The scaffolded `client.ts` uses the adapter instead of an HTTP connection, `chdb` is installed as a dependency automatically, and the credential prompts are skipped. Choose an on-disk session directory (e.g. `./analytics.chdb`) to persist data between runs, or stay in-memory for a throwaway runtime sandbox. After creating tables in an on-disk session, pass that same directory when refreshing types: ```bash npx hypequery generate --database chdb --chdb-path ./analytics.chdb ``` An in-memory session is discarded when its process exits. Use an on-disk path when tables created by your application must also be visible to a later `hypequery generate` process. Installing `chdb` does not prevent the CLI from connecting to ClickHouse Cloud or another remote server. `CLICKHOUSE_*` environment variables and `.env` configuration take precedence over dependency-based chDB detection. If neither remote configuration nor a `chdb` dependency is present, generation asks you to select a database explicitly. You can also select the remote driver explicitly: ```bash npx hypequery generate --database clickhouse ``` See the [CLI reference](/docs/reference/api/cli) for all `init` and `generate` flags. Setup [#setup] ```bash npm install chdb @hypequery/clickhouse ``` chDB ships prebuilt binaries for Linux x64/arm64 (glibc) and macOS x64/arm64 — no node-gyp step. Windows isn't supported natively; use WSL2. Swapping between embedded and remote is a one-line change: ```typescript // Local / test const db = createQueryBuilder({ adapter: chdbAdapter({ session }) }); // Production const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL, password: '...' }); ``` How It Works [#how-it-works] The adapter implements hypequery's `DatabaseAdapter` contract: hypequery compiles your query to SQL client-side, and the adapter executes it in-process via a chDB `Session`, returning `JSONEachRow` rows. The adapter is maintained by the chDB team — issues with the adapter itself belong on [chdb-io/chdb-node](https://github.com/chdb-io/chdb-node/issues). --- # Core concepts (/docs/core-concepts) hypequery is one query model that scales across three layers: * a **ClickHouse query builder** * **datasets** for semantic analytics definitions * an **optional runtime** for APIs, docs, and integrations You can adopt each layer independently and move between them as your needs grow. The mental model [#the-mental-model] Start with a local typed query. Promote it into a dataset when dimensions, measures, or filters need reuse. Add the runtime when the same logic needs an application boundary. | Layer | Use it when | Adds | | ------------------------- | ---------------------------------- | ------------------------------------------------- | | Query builder | Query logic is local to one place | Typed ClickHouse query construction and execution | | Datasets (semantic layer) | Logic needs reuse and consistency | Dimensions, measures, metrics, shared filters | | Runtime | Logic needs an application surface | Routes, validation, auth, docs, integrations | 1. Query builder [#1-query-builder] The builder is the foundation. This is where you: * connect to ClickHouse * generate a typed schema * build queries with `db.table(...)` * execute them directly ```ts import { createQueryBuilder } from '@hypequery/clickhouse'; import type { IntrospectedSchema } from './generated-schema'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const latestUsers = await db .table('users') .select(['id', 'email', 'created_at']) .where('status', 'eq', 'active') .orderBy('created_at', 'DESC') .limit(10) .execute(); ``` If your query only lives in one place, this is often enough. 2. Datasets (semantic layer) [#2-datasets-semantic-layer] Datasets turn table semantics into reusable analytics definitions. This is where you define: * **dimensions** such as `country`, `status`, and `created_at` * **measures** such as `userCount` and `activeUserCount` * shared filters, tenant keys, and time keys The key idea: datasets do not use `db.table(...)` directly. A dataset describes a source table, and a dataset client executes semantic queries against that definition. ```ts import { createDatasetClient, dataset, dimension, eq, measure, } from '@hypequery/datasets'; import { createQueryBuilder } from '@hypequery/clickhouse'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const analytics = createDatasetClient({ queryBuilder: db }); export const Users = dataset('users', { source: 'users', timeKey: 'created_at', dimensions: { id: dimension.string(), email: dimension.string(), status: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { userCount: measure.count('id'), activeUserCount: measure.count('id', { filters: [eq('status', 'active')], }), }, }); const result = await analytics.execute(Users, { dimensions: ['status'], measures: ['userCount', 'activeUserCount'], limit: 10, }); ``` Now the same semantics can be reused across APIs, dashboards, background jobs, and agent-facing tools without rewriting SQL. 3. Runtime (optional) [#3-runtime-optional] The runtime is a delivery layer for datasets and query definitions. Add it when you need: * HTTP routes * validation and input schemas * authentication or multi-tenancy * generated docs or OpenAPI * framework integrations ```ts import { initServe } from '@hypequery/serve'; import { db } from './client'; import { Users } from './datasets/users'; const { serve } = initServe({ context: () => ({ db }), }); export const api = serve({ datasets: { users: Users }, queryBuilder: db, }); ``` The runtime does not introduce a new query language. It exposes the same dataset definitions and builder-backed query logic through an application surface. One example, three stages [#one-example-three-stages] Stage 1 — local query [#stage-1--local-query] ```ts const activeUsers = await db .table('users') .select(['id', 'created_at']) .where('status', 'eq', 'active') .execute(); ``` Stage 2 — dataset [#stage-2--dataset] ```ts import { dataset, dimension, eq, measure } from '@hypequery/datasets'; export const Users = dataset('users', { source: 'users', timeKey: 'created_at', dimensions: { id: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { activeUsers: measure.count('id', { filters: [eq('status', 'active')], }), }, }); ``` Stage 3 — runtime [#stage-3--runtime] ```ts export const api = serve({ datasets: { users: Users }, queryBuilder: db, }); ``` You can also expose hand-authored builder queries through the same runtime: ```ts const { query, serve } = initServe({ context: () => ({ db }), }); const latestActiveUsers = query({ query: ({ ctx }) => ctx.db .table('users') .select(['id', 'email', 'created_at']) .where('status', 'eq', 'active') .orderBy('created_at', 'DESC') .limit(10) .execute(), }); export const api = serve({ queries: { latestActiveUsers }, datasets: { users: Users }, queryBuilder: db, }); ``` How to think about it [#how-to-think-about-it] hypequery is not separate systems stitched together. It is one model that evolves: * start with **typed queries** * organize repeated analytics semantics into **datasets** * expose them via the **runtime** when needed The same underlying logic stays consistent across every layer. When to move between layers [#when-to-move-between-layers] **Move to datasets when:** * the same dimensions, measures, or filters appear in multiple places * metrics need a single definition * queries should be reusable and consistent **Add the runtime when:** * queries or datasets should be accessible over HTTP * you need validation or auth * you want generated docs or integrations If you are unsure where to start, start with the builder. --- # CORS (/docs/cors) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; CORS [#cors] Use CORS when your frontend or external browser client calls a hypequery runtime from a different origin. Enable CORS [#enable-cors] The simplest option is: ```typescript const api = serve({ queries: { activeUsers }, cors: true, }); ``` That enables standard cross-origin handling for the runtime. Configure CORS explicitly [#configure-cors-explicitly] Use a config object when you need to control origins, headers, or methods: ```typescript const api = serve({ queries: { activeUsers }, cors: { origin: ['https://app.example.com', 'http://localhost:3000'], methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['content-type', 'authorization', 'x-api-key'], exposedHeaders: ['x-request-id'], credentials: true, maxAge: 86400, }, }); ``` CORS is a runtime-wide setting, so it applies to every endpoint the runtime serves — hand-written queries as well as auto-generated `metrics` and `datasets` endpoints. When `credentials: true`, `origin` must be an explicit origin string, allowlist, or validation function. Wildcard or omitted origins are rejected so authenticated responses cannot be exposed to arbitrary sites. When to use it [#when-to-use-it] * use CORS when your browser app calls a separate hypequery server * you usually do not need it when hypequery is mounted inside the same app origin * React hooks calling a different origin will typically need it * browser clients calling semantic `/metrics/*` or `/datasets/*` endpoints need it too See Also [#see-also] * [Authentication](/docs/authentication) * [Serve Runtime Reference](/docs/reference/api/runtime) * [React hooks](/docs/reference/api/react) --- # Embedded Runtime (/docs/embedded-runtime) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Embedded Runtime [#embedded-runtime] You don't need HTTP to execute hypequery metrics. Every `serve({ queries })` export exposes an embedded runtime so you can call queries directly from SSR routes, cron jobs, queues, or AI agents. Lifecycle [#lifecycle] 1. **Initialization** – Import your `analytics/queries.ts` (or equivalent) so your `initServe()` + `serve()` module runs. This wires up middleware, auth strategies, tenant config, docs/OpenAPI, etc. Make sure env vars are loaded before the import (e.g., `import 'dotenv/config'`). 2. **Context creation** – Whenever you call the runtime, hypequery builds a `ctx` object that includes the request metadata, auth context (if any), tenant helpers, and whatever you return from the `context` factory (e.g., `db`, cache clients, tracing IDs). 3. **Middleware + hooks** – Global and per-endpoint middleware run around your query, just like they do for HTTP requests. Lifecycle hooks (`onRequestStart`, `onRequestEnd`, etc.) fire as well, so logging and metrics stay consistent. 4. **Execution** – The query resolver executes against your ClickHouse connection (or any other resources you injected). If the resolver returns a value, hypequery serializes it exactly as it would for HTTP responses. 5. **Hot reload expectations** – During development the CLI reloads your `queries.ts` file automatically, so edits are picked up immediately. In production you control deployments; keep your `serve()` module instantiated once per process to avoid re-registering endpoints. Example Flows [#example-flows] Background Job / Cron Task [#background-job--cron-task]
    ```typescript
    import { api } from '../analytics/queries';

    export async function syncDailyRevenue() {
      const result = await api.run('dailyRevenue', {
        input: { start: '2024-01-01', end: '2024-01-31' },
        context: { jobId: crypto.randomUUID() },
      });

      await warehouse.insert('daily_metrics', result);
    }
    ```
  
* `api.run(key, options)` runs the endpoint in-process with the same validation, middleware, and hooks as HTTP (aliases: `api.execute`, `api.client`). * `input` must match the endpoint's `input` schema. If validation fails, the method throws an error containing the validation issues. * `context` lets you inject per-call data (job IDs, loggers, cache handles) that your middleware or resolver can read. API Handler (SSR / Server Action) [#api-handler-ssr--server-action]
    ```typescript
    import { api } from '../../analytics/queries';

    export async function GET() {
      const result = await api.run('activeUsers');
      return Response.json(result);
    }
    ```
  
This pattern keeps HTTP thin: the server component just forwards inputs to `api.run` and returns the result. You still benefit from Zod validation, middleware, and hooks. Test or Staging Harness [#test-or-staging-harness]
    ```typescript
    import { api } from '../analytics/queries';
    import { describe, it, expect } from 'vitest';

    describe('activeUsers metric', () => {
      it('returns the most recent rows', async () => {
        const result = await api.run('activeUsers', {
          input: { limit: 10 },
        });

        expect(result).toHaveLength(10);
      });
    });
    ```
  
Embedding metrics directly makes automated tests trivial: no HTTP servers to spin up, yet you still exercise the entire hypequery stack. Semantic datasets and metrics [#semantic-datasets-and-metrics] Auto-generated `metrics` and `datasets` endpoints are part of the same runtime, so they run embedded too. Reference them by key with `api.run(...)`: a metric uses its name, and a dataset uses the `dataset:` key.
    ```typescript
    import { api } from '../analytics/queries';

    // Metric endpoint, keyed by metric name.
    const revenueByCountry = await api.run('revenue', {
      input: { dimensions: ['country'], limit: 10 },
    });

    // Dataset endpoint, keyed as `dataset:`.
    const ordersRollup = await api.run('dataset:orders', {
      input: { dimensions: ['country'], measures: ['revenue', 'orderCount'] },
    });
    ```
  
Running them through `api.run(...)` keeps middleware, auth, tenant enforcement, and hooks consistent with their HTTP behavior. If you only need direct semantic execution without the Serve runtime around it, call `createDatasetClient(...).execute(target, query, context)` from `@hypequery/datasets` instead — see [Dataset execution](/docs/datasets/execution). Safety Checklist [#safety-checklist] * **Environment variables** – Load creds before importing `analytics/queries.ts`. In ESM/TS projects the easiest option is `import 'dotenv/config'` in your entrypoint. * **Auth context** – If you rely on `auth` strategies, pass a `request` shape to `api.run` via `options.request`. That ensures the strategy receives headers/tokens. * **Tenant enforcement** – Global/per-endpoint `tenant` configs still apply. For background jobs that legitimately bypass tenant checks, disable the tenant config on that endpoint. * **Error handling** – `api.run` throws when the endpoint would have returned an error response. Wrap calls in try/catch to handle validation failures or ClickHouse errors gracefully. With these patterns you can run hypequery definitions anywhere in your runtime. --- # FAQ (/docs/faq) import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; Frequently Asked Questions [#frequently-asked-questions] General [#general] hypequery is a code-first analytics layer for ClickHouse. It gives TypeScript teams a typed query builder, reusable query definitions, and an optional serve runtime for HTTP, docs, and framework handlers. Yes. hypequery is used in production environments for typed, reusable analytics queries. Currently ClickHouse only. Yes. hypequery is open source and available on GitHub. Usage [#usage] Yes. Use `raw()` for SQL fragments or `rawQuery()` when you need a full raw SQL query. hypequery is optimized for analytics workloads. For mutations, use `rawQuery()`. Yes. Treat materialized views as regular tables in your generated schema. Use `isNull` and `isNotNull` operators in the query builder. React Hooks [#react-hooks] No. `@hypequery/react` is optional and only needed for React applications. No. React hooks require the serve runtime because they target HTTP routes generated from your API surface. Yes. See the [Next.js guide](/docs/nextjs). Performance & Caching [#performance--caching] Yes. hypequery supports query-result caching at the query builder layer with strategies such as `cache-first`, `network-first`, and `stale-while-revalidate`. Yes. Use `stream()` or `streamForEach()`. Deployment [#deployment] Anywhere Node.js runs, including serverless platforms, containers, traditional servers, and compatible edge runtimes. No. `hypequery dev` is only for local development. In production, use `api.run()` for in-process execution or mount `api.handler` / call `api.start()` for HTTP delivery. --- # Fetch Runtime Integration (/docs/fetch) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Fetch Runtime Integration [#fetch-runtime-integration] This guide assumes you already have: * a typed `db` client * an `analytics/queries.ts` file exporting `api` * at least one routed query If not, start with [Quick Start](/docs/quick-start). Use this pattern in Hono, Cloudflare Workers, Deno, Bun, Remix, and similar runtimes: ```typescript import { Hono } from 'hono'; import { api } from './analytics/queries.js'; import { createFetchHandler } from '@hypequery/serve'; const app = new Hono(); const hypequery = createFetchHandler(api.handler); app.all('/api/analytics/*', (c) => hypequery(c.req.raw)); ``` Notes [#notes] * Keep the mounted prefix aligned with the `basePath` used in `initServe()`. * Use `api.run(...)` when you want in-process execution instead of HTTP. * Export the handler directly in runtimes that do not need an extra router layer. --- # HTTP + OpenAPI (/docs/http-openapi) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; HTTP + OpenAPI Delivery [#http--openapi-delivery] hypequery can expose your queries as HTTP endpoints with automatically generated OpenAPI documentation. Quick Example [#quick-example] Here's a complete example of exposing a query via HTTP: **1. Define your query:**
    ```typescript
    // src/analytics/queries.ts
    import { initServe } from '@hypequery/serve';
    import { z } from 'zod';
    import { db } from './client';

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

    const revenue = query({
      description: 'Get total revenue',
      output: z.object({
        total: z.number(),
        count: z.number(),
      }),
      query: async ({ ctx }) => {
        const rows = await ctx.db
          .table('orders')
          .sum('amount', 'total')
          .count('order_id', 'count')
          .execute();

        return rows[0];
      },
    });

    export const api = serve({
      queries: { revenue },
    });

    // Optional: expose the query at a custom path/method.
    // Without this, the query is already reachable at POST /queries/revenue.
    api.route('/revenue', api.queries.revenue, { method: 'POST' });
    ```
  
**2. Start the server:**
    ```bash
    npx hypequery dev src/analytics/queries.ts
    # Server running at http://localhost:4000
    ```
  
> `hypequery dev` spins up the same HTTP server your `api.handler` uses. Every registered query is reachable at `POST /queries/` automatically; `api.route(...)` is only needed when you want a custom path or method (like the `/revenue` route above). In production you can do the equivalent by calling `await api.start({ port })`, or by embedding `api.handler` inside your own framework/server if you don't want to rely on the CLI entry point. **3. Call your API:**
    ```bash
    curl -X POST http://localhost:4000/revenue
    # {"total": 125000, "count": 450}
    ```
  
That's it! Your query is now available as an HTTP endpoint with auto-generated OpenAPI docs at `http://localhost:4000/docs`. Deployment Models [#deployment-models] Embedded in Framework (Recommended for Web Apps) [#embedded-in-framework-recommended-for-web-apps] Integrate hypequery directly into your web framework. Routes run on the **same port** as your application. **Supported frameworks:** * Next.js (Vercel adapter) * Express * Hono * Any framework with standard Request/Response handlers **Example: Next.js**
    ```typescript
    // app/api/hypequery/[...hq]/route.ts
    import { api } from '@/analytics/queries';
    import { createFetchHandler } from '@hypequery/serve';

    export const runtime = 'nodejs';

    const handler = createFetchHandler(api.handler);

    export const GET = handler;
    export const POST = handler;
    export const OPTIONS = handler;
    ```
  
**Example: Express** Use the Node adapter to mount hypequery alongside your existing routes:
    ```typescript
    // server.ts
    import express from 'express';
    import { createNodeHandler } from '@hypequery/serve';
    import { api } from './analytics/queries';

    const app = express();

    // Mount hypequery at /api/analytics
    app.use('/api/analytics', createNodeHandler(api.handler));

    app.listen(3000);
    ```
  
Standalone Server [#standalone-server] Run a dedicated hypequery server on its own port:
    ```typescript
    // src/analytics/server.ts
    import { api } from './queries';

    await api.start({ port: 4000 });
    ```
  
`api.start()` starts the default Node server and returns a `{ stop() }` handle for shutdown. If you need custom app routes, mount `api.handler` inside your own Express, Hono, or Next.js server instead of expecting an app object from `start()`. For local development, use the CLI:
    ```bash
    npx hypequery dev src/analytics/queries.ts --port 4000
    ```
  
In production, start the server yourself with `api.start({ port })` (above) or mount `api.handler` inside your own framework — the CLI's `dev` command is for local development, not production serving. Edge/Fetch Runtimes [#edgefetch-runtimes] Deploy to edge platforms using the standard Fetch API:
    ```typescript
    // Cloudflare Worker
    import { api } from './analytics/queries';
    import { createFetchHandler } from '@hypequery/serve';

    const handler = createFetchHandler(api.handler);

    export default {
      fetch(request: Request) {
        return handler(request);
      },
    };

    // Vercel Edge Function
    import { api } from './analytics/queries';
    import { createFetchHandler } from '@hypequery/serve';

    const fetchHandler = createFetchHandler(api.handler);

    export default function handler(request: Request) {
      return fetchHandler(request);
    }
    ```
  
OpenAPI Documentation [#openapi-documentation] Auto-generated OpenAPI specs are available at `/openapi.json`:
    ```bash
    curl http://localhost:4000/openapi.json
    ```
  
Customize the generated OpenAPI document through `serve({ openapi: ... })`:
    ```typescript
    const api = serve({
      queries: { revenue },
      openapi: {
        title: 'My Analytics API',
        version: '2.0.0',
        servers: [
          { url: 'https://api.example.com', description: 'Production' },
        ],
      },
    });
    ```
  
Semantic dataset and metric endpoints [#semantic-dataset-and-metric-endpoints] Alongside hand-written `queries`, `serve({ ... })` accepts `metrics` and `datasets` built with `@hypequery/datasets`. These generate governed POST endpoints automatically — you do **not** call `api.route(...)` for them, and they appear in `/openapi.json` and `/docs` like any other endpoint.
    ```typescript
    // src/analytics/queries.ts
    import { initServe } from '@hypequery/serve';
    import { db } from './client';
    import { Orders, revenue } from './datasets/orders';

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

    export const api = serve({
      // Required whenever metrics or datasets are registered.
      queryBuilder: db,
      metrics: { revenue },
      datasets: { orders: Orders },
    });
    ```
  
This registers: | Config | Generated endpoint | | ------------------------------ | ----------------------------- | | `metrics: { revenue }` | `POST /metrics/revenue` | | `datasets: { orders: Orders }` | `POST /datasets/orders/query` | Both validate the request body (dimensions, measures, filters, ordering, time grain) against the metric or dataset contract before executing. A requested `limit` above the endpoint's `maxLimit` is **clamped** to `maxLimit` rather than rejected. `queryBuilder` is required whenever `metrics` or `datasets` are present; it can also be supplied through `context` as `context: () => ({ db })`. Customize the path prefixes with `semanticPaths`:
    ```typescript
    export const api = serve({
      queryBuilder: db,
      metrics: { revenue },
      datasets: { orders: Orders },
      semanticPaths: {
        metrics: '/api/metrics',
        datasets: '/api/data',
      },
    });
    // POST /api/metrics/revenue
    // POST /api/data/orders/query
    ```
  
For per-entry options (auth, caching, limits) and request/response shapes, see [Serve integration](/docs/datasets/serve-integration). Pagination [#pagination] Semantic endpoints support offset pagination. Send `limit` and `offset` in the request body, and the response reports the `offset` it served along with `hasMore`, so clients know whether to fetch the next page.
    ```bash
    curl -X POST http://localhost:4000/datasets/orders/query \
      -H 'content-type: application/json' \
      -d '{ "dimensions": ["country"], "measures": ["revenue"], "limit": 50, "offset": 50 }'
    ```
  
In React, `useInfiniteMetric` and `useInfiniteDataset` consume `hasMore`/`offset` for you — see the [React API reference](/docs/reference/api/react). Result metadata [#result-metadata] Semantic endpoints return `{ data }` by default. Opt into metadata (generated SQL, timing, row count, tenant, pagination) by sending `includeMeta: true` in the request body or the `x-include-meta: true` header, which switches the response to `{ data, meta }`.
    ```bash
    curl -X POST http://localhost:4000/datasets/orders/query \
      -H 'content-type: application/json' \
      -H 'x-include-meta: true' \
      -d '{ "dimensions": ["country"], "measures": ["revenue"], "limit": 10 }'
    ```
  
Documentation UI [#documentation-ui] Serve interactive API documentation from the built-in `/docs` route, or customize the generated page with `serve({ docs: ... })`:
    ```typescript
    const api = serve({
      queries: { revenue },
      docs: {
        title: 'My API Docs',
        subtitle: 'Analytics runtime',
        darkMode: true,
      },
    });
    ```
  
The runtime will expose that UI at `/docs` automatically once routes are registered. If you want to host the docs HTML yourself, use the exported helper:
    ```typescript
    import { buildDocsHtml } from '@hypequery/serve';

    app.get('/docs', (req, res) => {
      res.send(buildDocsHtml('/openapi.json', {
        title: 'My API Docs',
        subtitle: 'Analytics runtime',
        darkMode: true,
      }));
    });
    ```
  
Custom Routes [#custom-routes] Register individual queries with custom paths and methods:
    ```typescript
    import { api } from './analytics/queries';

    // Register with custom path
    api.route('/analytics/revenue', api.queries.revenue, { method: 'POST' });

    // Register as GET
    api.route('/health', api.queries.healthCheck, { method: 'GET' });

    // Use query metadata from definition
    api.route('/weekly', api.queries.weeklyRevenue);
    ```
  
Input on GET routes [#input-on-get-routes] `GET` endpoints read their input from the query string. Query strings carry no types — every value arrives as a string — so hypequery coerces each value toward what your schema declares before validating it.
    ```typescript
    const busiestRoutes = query({
      input: z.object({
        minTrips: z.number().int().default(500),
        limit: z.number().int().max(100).default(10),
        verbose: z.boolean().optional(),
      }),
      query: async ({ input }) => { /* ... */ },
    });
    ```
  
    ```bash
    curl '/api/analytics/queries/busiestRoutes?minTrips=500&limit=8&verbose=true'
    # input === { minTrips: 500, limit: 8, verbose: true }
    ```
  
Numbers, booleans, bigints, and dates are converted; strings and enums are left alone. A repeated key (`?ids=1&ids=2`) becomes an array, and a single occurrence of a key typed as `z.array(...)` is wrapped into one. Coercion is conservative: anything it cannot convert is passed through unchanged so validation reports the real problem. `?limit=abc` still fails with `expected number, received string` rather than a misleading error about a value the runtime invented. Constraints apply as normal — `?limit=5000` is coerced to `5000` and then rejected by `.max(100)`. An explicit `z.preprocess(...)` is a coercion boundary. Its callback receives the raw query value and owns conversion for the schema subtree it wraps. This keeps existing preprocessors predictable; use `z.coerce.*` inside that subtree or return the converted value from the callback when conversion is required. `POST` bodies are untouched. JSON already carries types, so a body value of `"5"` stays the string `"5"`. --- # Introduction (/docs/introduction) import { Book, Braces, Github, Zap } from 'lucide-react'; A TypeScript semantic layer for ClickHouse [#a-typescript-semantic-layer-for-clickhouse] hypequery gives TypeScript teams one place to define ClickHouse analytics and reuse them in backend code, HTTP APIs, React dashboards, and MCP tools for AI agents. It is an open-source library that runs inside your application—not a hosted BI platform or a separate semantic-layer server. Start with the type-safe query builder when a query is local. Add a dataset when dimensions, measures, metrics, tenant isolation, and time grains become shared product meaning. Add Serve, React, or MCP only when another consumer needs the same contract. The canonical dataset [#the-canonical-dataset] ```ts import { dataset, dimension, measure } from '@hypequery/datasets'; export const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { id: dimension.string(), region: dimension.string(), status: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), orderCount: measure.count('id'), p95OrderValue: measure.percentile('amount', 0.95), latestStatus: measure.argMax('status', 'createdAt'), }, }); export const revenue = Orders.metric('revenue', { measure: 'revenue', label: 'Revenue', }); ``` This is ordinary TypeScript: review it in a pull request, test it in CI, import it in a worker, serve it as an API, consume it with a typed React hook, or expose it as a bounded MCP tool. The packages [#the-packages] | Package | Role | | ----------------------- | --------------------------------------------------------------------------------------------------------- | | `@hypequery/clickhouse` | Schema generation and a type-safe ClickHouse query builder | | `@hypequery/datasets` | Code-first semantic datasets, dimensions, measures, metrics, relationships, time grains, and tenant rules | | `@hypequery/serve` | Validated HTTP APIs, OpenAPI, authentication, tenancy, and runtime adapters | | `@hypequery/react` | Typed TanStack Query hooks for queries, metrics, and datasets | | `@hypequery/mcp` | Governed ClickHouse tools for Claude, Cursor, and other MCP clients | | `@hypequery/cli` | Scaffolding, schema generation, local docs, and deployment commands | Choose your starting point [#choose-your-starting-point] 1. Run `npx hypequery init` if you want a working project scaffold. 2. Use `@hypequery/clickhouse` directly if you only need typed local queries. 3. Define `@hypequery/datasets` when analytics meaning is shared. 4. Add `serve()`, React hooks, or MCP when the model needs to cross a process boundary.
Quick start

Connect ClickHouse, generate types, and run the first query

Current capabilities

Check the shipped builder, aggregate, dataset, React, and MCP surface

Query builder

Write typed ClickHouse filters, joins, aggregations, and native clauses

Example apps

Browse complete framework and dashboard examples

The progression in code [#the-progression-in-code] A query can begin as a local builder chain: ```ts const revenueByRegion = await db .table('orders') .select(['region']) .sum('amount', 'revenue') .groupBy('region') .execute(); ``` When it becomes reusable business meaning, promote it into the `Orders` dataset. When it needs an HTTP contract, pass the metric and dataset to Serve: ```ts const { serve } = initServe({ context: () => ({ db }), }); export const api = serve({ queryBuilder: db, metrics: { revenue }, datasets: { orders: Orders }, }); ``` That API can be called in process, over HTTP, from `@hypequery/react`, or through `@hypequery/mcp`. The definition and tenant rules stay in one place. Next steps [#next-steps] * [Quick start](/docs/quick-start) * [What hypequery supports today](/docs/capabilities) * [Datasets overview](/docs/datasets/overview) * [Multi-tenant datasets](/docs/datasets/multi-tenancy) * [React hooks](/docs/react/getting-started) * [MCP for ClickHouse](/docs/mcp/overview) --- # Multi-Tenancy (/docs/multi-tenancy) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Multi-Tenancy Isolation [#multi-tenancy-isolation] Tenant configuration is enforced by the `serve({ queries })` runtime and applies to the query definitions you expose there. Model [#model] * extract a tenant ID from `ctx.auth` * reject requests when tenant context is required but missing * optionally auto-inject tenant filters into query builders in `ctx` * reuse the same tenant-aware runtime for `api.execute(...)`, `api.run(...)`, and HTTP delivery With `mode: 'auto-inject'`, hypequery injects tenant filters into compatible query builders in context. That is the recommended mode for SaaS applications. Global tenant configuration [#global-tenant-configuration] ```typescript import { initServe } from '@hypequery/serve'; import { z } from 'zod'; type AppAuth = { userId: string; tenantId: string }; const authStrategy = async ({ request }): Promise => { const token = request.headers['authorization']; if (!token) return null; const decoded = await verifyToken(token); return { userId: decoded.sub, tenantId: decoded.organization_id }; }; const { query, serve } = initServe({ auth: authStrategy, context: () => ({ db: myDatabaseConnection, }), }); const getOrders = query({ input: z.object({ status: z.string().optional() }), query: ({ ctx, input }) => ctx.db .table('orders') .where('status', 'eq', input.status ?? 'completed') .select('*') .execute(), }); export const api = serve({ tenant: { extract: (auth) => auth.tenantId, required: true, column: 'organization_id', mode: 'auto-inject', }, queries: { getOrders }, }); ``` Configuration options [#configuration-options] extract [#extract] Use `extract` to read a tenant ID from your auth object. column [#column] Set the database column used for tenant filtering when you use `mode: 'auto-inject'`. mode [#mode] * `auto-inject` automatically applies tenant filters to compatible builders in context * `manual` leaves tenant filtering up to your query logic required [#required] When `required` is `true`, requests without a tenant ID are rejected. Auto-inject mode [#auto-inject-mode] Auto-inject mode is the safest option because it makes tenant filtering the runtime default: ```typescript const { query, serve } = initServe({ context: () => ({ db: myDb, analyticsDb: myAnalyticsDb, }), }); const getUsers = query({ query: ({ ctx }) => ctx.db.table('users').select('*').execute(), }); export const api = serve({ tenant: { extract: (auth) => auth.tenantId, column: 'org_id', mode: 'auto-inject', }, queries: { getUsers }, }); ``` Manual mode [#manual-mode] Use manual mode when you need to control tenant predicates yourself: ```typescript const { query, serve } = initServe({ context: () => ({ db: myDb }), }); const getUsers = query({ query: ({ ctx }) => ctx.db .table('users') .where('organization_id', 'eq', ctx.tenantId) .select('*') .execute(), }); export const api = serve({ tenant: { extract: (auth) => auth.tenantId, mode: 'manual', }, queries: { getUsers }, }); ``` Per-query tenant overrides [#per-query-tenant-overrides] Object-style `query({ ... })` supports per-query tenant overrides. ```typescript const { query, serve } = initServe({ context: () => ({ db }), }); const orders = query({ query: ({ ctx }) => ctx.db.table('orders').select('*').execute(), }); const adminStats = query({ tenant: { required: false, mode: 'manual', }, query: ({ ctx }) => { if (ctx.tenantId) { return ctx.db.table('stats').where('tenant_id', 'eq', ctx.tenantId).execute(); } return ctx.db.table('stats').select('*').execute(); }, }); export const api = serve({ tenant: { extract: (auth) => auth.tenantId, column: 'tenant_id', mode: 'auto-inject', }, queries: { orders, adminStats }, }); ``` Use per-query overrides when one query needs different tenant behavior than the global runtime: * `required: false` makes tenant context optional for that query * `mode: 'manual'` disables auto-injection for that query * if no global tenant config exists, include `extract` in the per-query override Standalone `query.execute(...)` does not run the tenant pipeline. Use `api.execute(...)`, `api.run(...)`, or an HTTP route when tenant enforcement matters. Semantic dataset and metric endpoints [#semantic-dataset-and-metric-endpoints] When you register `datasets` or `metrics` with `serve({ ... })`, tenant enforcement works differently than it does for hand-written builder queries. Semantic endpoints do **not** read the serve `tenant.column`. Instead: * serve `tenant.extract` and `tenant.required` supply the **trusted tenant identity** from auth * the **filter column comes from the dataset's own `tenantKey`**, declared on `dataset(...)` * datasets auto-inject the tenant predicate from `tenantKey`, just like `createDatasetClient` does outside Serve In other words, `tenant.column` governs builder queries, while a dataset's `tenantKey` governs its semantic endpoint. ```typescript import { initServe } from '@hypequery/serve'; import { db } from './client'; import { Orders, revenue } from './datasets/orders'; const { serve } = initServe({ auth: authStrategy, context: () => ({ db }), }); export const api = serve({ queryBuilder: db, metrics: { revenue }, datasets: { orders: Orders }, tenant: { extract: (auth) => auth.tenantId, required: true, // `column` is only used for builder queries. Dataset/metric endpoints // filter on the dataset's own `tenantKey` instead. }, }); ``` If a registered dataset declares a `tenantKey` and a request carries a tenant identity, the endpoint requires both the serve tenant runtime and the dataset `tenantKey`. Missing either one is a configuration error rather than a silently unscoped query. Tenant identity for semantic endpoints must come from trusted runtime state via `tenant.extract`. Callers cannot pass tenant filters in the request body — explicit filters on the tenant field are rejected. See Dataset multi-tenancy for the full dataset-side model. See Also [#see-also] * [Authentication](/docs/authentication) * [Serve Runtime Reference](/docs/reference/api/runtime) --- # Next.js (/docs/nextjs) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Step, Steps } from 'fumadocs-ui/components/steps'; Next.js [#nextjs] This guide assumes you already have: * a typed `db` client * an `analytics/queries.ts` file exporting `api` * at least one routed query If not, start with [Quick Start](/docs/quick-start). Mount the handler in App Router [#mount-the-handler-in-app-router] Create `app/api/analytics/[...path]/route.ts`: ```typescript import { api } from '@/analytics/queries'; import { createFetchHandler } from '@hypequery/serve'; const handler = createFetchHandler(api.handler); export const runtime = 'nodejs'; export const GET = handler; export const POST = handler; export const OPTIONS = handler; ``` `createFetchHandler` is exported from the root `@hypequery/serve` package, so you do not need to import from an adapter subpath. Execute queries locally in server code [#execute-queries-locally-in-server-code] Use the same definition without HTTP in server components, actions, and jobs: ```typescript import { api } from '@/analytics/queries'; export const dynamic = 'force-dynamic'; export default async function Page() { const stats = await api.run('dailyStats', { input: { startDate: '2025-01-01T00:00:00Z', endDate: '2025-01-31T23:59:59Z', }, }); return
{JSON.stringify(stats, null, 2)}
; } ```
Use `force-dynamic` on Server Components or pages that query ClickHouse during render. Without it, Next.js may try to prerender the page at build time and fail or time out when the database is unavailable.
Call the API from client components [#call-the-api-from-client-components] Client components cannot import the serve API as a value — doing so bundles `initServe` and `@hypequery/clickhouse` for the browser and fails the build with `Can't resolve 'fs/promises'`. Generate the route manifest as static JSON instead: ```bash npx hypequery generate:manifest analytics/queries.ts --output analytics/hypequery-manifest.json ``` ```typescript // lib/analytics.ts import { createAnalyticsHooks } from '@hypequery/react'; import type { InferApiType } from '@hypequery/serve'; import type { api } from '@/analytics/queries'; import manifest from '@/analytics/hypequery-manifest.json'; type Api = InferApiType; export const { useQuery, useMetric, useDataset } = createAnalyticsHooks({ baseUrl: '/api/analytics', manifest, }); ``` Both `api` and `InferApiType` are imported with `import type`, so they are erased at build time and only the manifest JSON survives into the client bundle. See the [React guide](/docs/react/getting-started) for provider setup. Preview docs locally [#preview-docs-locally] ```bash npx hypequery dev analytics/queries.ts ``` With `basePath: '/api/analytics'`, the preview server exposes: * docs at `http://localhost:4000/api/analytics/docs` * OpenAPI at `http://localhost:4000/api/analytics/openapi.json`
--- # Node.js (/docs/nodejs) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Node.js [#nodejs] This guide assumes you already have: * a typed `db` client * an `analytics/queries.ts` file exporting `api` * at least one routed query If not, start with [Quick Start](/docs/quick-start). Install the HTTP runtime [#install-the-http-runtime] ```bash npm install hono @hono/node-server dotenv npm install -D tsx typescript ``` Mount hypequery inside Hono [#mount-hypequery-inside-hono] Create `src/app.ts`: ```typescript import { Hono } from 'hono'; import { api } from '../analytics/queries.js'; import { createFetchHandler } from '@hypequery/serve'; const hypequery = createFetchHandler(api.handler); export const app = new Hono(); app.get('/', (c) => c.json({ status: 'ok' })); app.all('/api/analytics/*', (c) => hypequery(c.req.raw)); ``` Start the server [#start-the-server] Create `src/index.ts`: ```typescript import 'dotenv/config'; import { serve } from '@hono/node-server'; import { app } from './app.js'; serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000), }); ``` Use [Fetch Runtime Integration](/docs/fetch) if you want the same pattern in Workers, Bun, or other Fetch runtimes. --- # Observability (/docs/observability) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Observability [#observability] hypequery has two observability layers: * runtime observability in `@hypequery/serve` * builder-level query logging in `@hypequery/clickhouse` Runtime observability [#runtime-observability] Use runtime hooks and query logging when you want visibility into requests flowing through `serve({ queries })`. Lifecycle hooks [#lifecycle-hooks]
    ```typescript
    const api = serve({
      queries: { activeUsers },
      hooks: {
        onRequestStart: async (event) => {
          console.log('start', event.queryKey);
        },
        onRequestEnd: async (event) => {
          console.log('end', event.queryKey, event.durationMs);
        },
        onAuthFailure: async (event) => {
          console.warn('auth failed', event.queryKey, event.reason);
        },
        onAuthorizationFailure: async (event) => {
          console.warn('forbidden', event.queryKey, event.reason, event.required);
        },
        onError: async (event) => {
          console.error('error', event.queryKey, event.error);
        },
      },
    });
    ```
  
Query logging [#query-logging]
    ```typescript
    const api = serve({
      queries: { activeUsers },
      queryLogging: 'json',
      slowQueryThreshold: 2_000,
    });
    ```
  
`queryLogging` accepts: * `true` * `'json'` * `(event) => void` Lifecycle hooks currently support: * `onRequestStart` * `onRequestEnd` * `onAuthFailure` * `onAuthorizationFailure` * `onError` Hooks and query logging fire for auto-generated `metrics` and `datasets` endpoints too, since they share the runtime with queries. In hook events, `event.queryKey` carries the endpoint key: the metric name for metric endpoints, and `dataset:` for dataset endpoints. That makes it easy to attribute timing, auth failures, and errors back to a specific semantic endpoint. Builder-level query logging [#builder-level-query-logging] Use the ClickHouse logger when you want SQL-level visibility into the query builder itself.
    ```typescript
    import { logger } from '@hypequery/clickhouse';

    logger.configure({
      enabled: true,
      level: 'debug',
      onQueryLog: (log) => {
        console.log(log.query, log.duration, log.status);
      },
    });
    ```
  
This is the right layer when you care about: * generated SQL * parameters * row counts * cache metadata * low-level ClickHouse failures When to use each layer [#when-to-use-each-layer] * use runtime observability for API-level timing, auth failures, and request lifecycle events * use builder logging for SQL-level analysis and ClickHouse debugging * use both when you want end-to-end visibility from route to database See Also [#see-also] * [Runtime Features](/docs/runtime-features) * [Rate Limiting](/docs/rate-limiting) * [Query Caching](/docs/query-building/caching) --- # Quick Start (/docs/quick-start) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { File, Folder, Files } from 'fumadocs-ui/components/files'; import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; You can run the exact same builder code on [chDB](https://github.com/chdb-io/chdb-node). ClickHouse embedded in your node process by passing its adapter: `createQueryBuilder({ adapter: chdbAdapter({ session }) })`. Read how to get started [here](/docs/chdb). Choose your route [#choose-your-route] This page has two paths: * **Route 1: Queries** if you want typed ClickHouse queries running in-process as quickly as possible * **Route 2: Datasets** if you want a reusable semantic layer — dimensions, measures, and metrics defined once and queried by name Both routes can optionally be exposed over HTTP with the [Serve runtime](#optional-expose-over-http-with-serve) when you need named endpoints, validation, docs, or OpenAPI. Start with whichever route fits, and add Serve later if you need it. Route 1: Queries [#route-1-queries] If you only need typed ClickHouse queries in your application code, start here: Install the packages ```bash npm install @hypequery/clickhouse npm install -D @hypequery/cli ``` ```bash pnpm add @hypequery/clickhouse pnpm add -D @hypequery/cli ``` ```bash yarn add @hypequery/clickhouse yarn add -D @hypequery/cli ``` ```bash bun add @hypequery/clickhouse bun add -D @hypequery/cli ``` Configure your ClickHouse env vars Create `.env`: ```bash CLICKHOUSE_URL=https://example.clickhouse.cloud:8443 CLICKHOUSE_DATABASE=default CLICKHOUSE_USERNAME=cli_user CLICKHOUSE_PASSWORD=super-secret ``` `hypequery generate` uses these values to connect to ClickHouse during schema introspection. Generate your schema types Use the CLI to introspect ClickHouse and write your TypeScript schema: ```bash npx hypequery generate ``` This writes `analytics/schema.ts`, matching the CLI default and the import below. Re-run it anytime your ClickHouse schema changes. Connect and run a typed query ```typescript // analytics/client.ts import { createQueryBuilder } from '@hypequery/clickhouse'; import type { IntrospectedSchema } from './schema.js'; export const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USERNAME, password: process.env.CLICKHOUSE_PASSWORD, database: process.env.CLICKHOUSE_DATABASE, }); const users = await db .table('users') .select(['id', 'email', 'created_at']) .where('status', 'eq', 'active') .orderBy('created_at', 'DESC') .limit(10) .execute(); ``` If that is all you need, continue with [Query Basics](/docs/query-building/basics). Or read more about [connecting to ClickHouse](/docs/reference/connection). Route 2: Datasets [#route-2-datasets] In this route you define a **dataset** — a typed semantic model over a ClickHouse table — once, then query it by selecting dimensions and measures. The same definition powers ad hoc rollups, reusable metrics, and (optionally) governed HTTP endpoints. This is the right route when query logic should be shared: when the same table semantics, tenant scoping, and KPIs need to be consistent across jobs, dashboards, APIs, and agents. Install the packages ```bash npm install @hypequery/datasets @hypequery/clickhouse npm install -D @hypequery/cli ``` ```bash pnpm add @hypequery/datasets @hypequery/clickhouse pnpm add -D @hypequery/cli ``` ```bash yarn add @hypequery/datasets @hypequery/clickhouse yarn add -D @hypequery/cli ``` ```bash bun add @hypequery/datasets @hypequery/clickhouse bun add -D @hypequery/cli ``` Configure your ClickHouse env vars Create `.env`: ```bash CLICKHOUSE_URL=https://example.clickhouse.cloud:8443 CLICKHOUSE_DATABASE=default CLICKHOUSE_USERNAME=cli_user CLICKHOUSE_PASSWORD=super-secret ``` Generate dataset definitions Use the CLI to introspect ClickHouse and scaffold dataset definitions from your live schema: ```bash npx hypequery generate:datasets ``` This writes `analytics/datasets.ts` by default. Generated exports use names like `OrdersDataset`; the examples below alias or rename that export to `Orders` after curation. Re-run generation when your schema changes, then curate the generated dimensions and measures so the semantic names match your product language. Connect a query builder and dataset client `@hypequery/datasets` plans and validates semantic queries; `@hypequery/clickhouse` constructs and executes them. The dataset client wraps the same query builder you would use for hand-written queries. ```typescript // analytics/client.ts import { createQueryBuilder } from '@hypequery/clickhouse'; import { createDatasetClient } from '@hypequery/datasets'; export const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USERNAME!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); export const analytics = createDatasetClient({ queryBuilder: db }); ``` Review or customize a dataset The generated file gives you a starting point. A curated dataset maps a source table to typed dimensions and measures, plus optional tenant and time keys: ```typescript // analytics/datasets/orders.ts import { dataset, dimension, measure } from '@hypequery/datasets'; export const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { id: dimension.number(), status: dimension.string(), country: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), orderCount: measure.count('id'), }, }); ``` Query the dataset Select any combination of dimensions and measures. `execute` validates the request, generates SQL, runs it, and returns rows plus metadata. ```typescript import { eq } from '@hypequery/datasets'; import { analytics } from './client.js'; import { Orders } from './datasets.js'; const byCountry = await analytics.execute(Orders, { dimensions: ['country'], measures: ['revenue', 'orderCount'], filters: [eq('status', 'completed')], orderBy: [{ field: 'revenue', direction: 'desc' }], limit: 10, }); console.log(byCountry.data); ``` Promote reusable KPIs into metrics When a measure becomes a KPI you reference repeatedly, give it a name as a **metric**. Derived metrics compose base metrics from the same dataset. ```typescript // analytics/metrics.ts import { divide, eq, nullIfZero } from '@hypequery/datasets'; import { analytics } from './client.js'; import { Orders } from './datasets.js'; export const revenue = Orders.metric('revenue', { measure: 'revenue' }); export const orderCount = Orders.metric('orderCount', { measure: 'orderCount' }); export const averageOrderValue = Orders.metric('averageOrderValue', { uses: { revenue, orderCount }, formula: ({ revenue, orderCount }) => divide(revenue, nullIfZero(orderCount)), }); const aovByCountry = await analytics.execute(averageOrderValue, { dimensions: ['country'], filters: [eq('status', 'completed')], orderBy: [{ field: 'averageOrderValue', direction: 'desc' }], limit: 10, }); ``` If that is all you need, continue with the [Datasets Overview](/docs/datasets/overview), or dig into [Dimensions](/docs/datasets/dimensions), [Measures](/docs/datasets/measures), and [Metrics](/docs/datasets/metrics). Optional: expose over HTTP with serve [#optional-expose-over-http-with-serve] Both routes stay in-process until you need an HTTP boundary. Add `@hypequery/serve` when the same logic should have a stable URL, input validation, docs, OpenAPI, auth, or React hooks. Add `@hypequery/react` when a React or Next.js UI should call those served endpoints through typed TanStack Query hooks. ```bash npm install @hypequery/serve zod@^3 npm install @hypequery/react @tanstack/react-query ``` ```bash pnpm add @hypequery/serve zod@^3 pnpm add @hypequery/react @tanstack/react-query ``` ```bash yarn add @hypequery/serve zod@^3 yarn add @hypequery/react @tanstack/react-query ``` ```bash bun add @hypequery/serve zod@^3 bun add @hypequery/react @tanstack/react-query ``` `@hypequery/serve` and `@hypequery/datasets` build their validation types against zod 3. Installing a bare `zod` today resolves zod 4, whose schema types are structurally incompatible — your first `query({ input: z.object(...) })` will fail to compile with `Type 'ZodObject<...>' is missing the following properties from type 'ZodType'`. Pin `zod@^3`. From Route 1: serve a named query [#from-route-1-serve-a-named-query] Wrap a builder query with `query({ ... })` to turn it into a validated, named contract, then expose it with `serve`. ```typescript // analytics/queries.ts import { initServe } from '@hypequery/serve'; import { z } from 'zod'; import { db } from './client.js'; const { query, serve } = initServe({ context: () => ({ db }), basePath: '/api/analytics', }); const activeUsers = query({ description: 'Most recent active users', input: z.object({ limit: z.number().min(1).max(500).default(50), }), output: z.array(z.object({ id: z.string(), email: z.string(), created_at: z.string(), })), query: async ({ ctx, input }) => ctx.db .table('users') .select(['id', 'email', 'created_at']) .where('status', 'eq', 'active') .orderBy('created_at', 'DESC') .limit(input.limit) .execute(), }); export const api = serve({ queries: { activeUsers }, }); api.route('/active-users', api.queries.activeUsers, { method: 'POST' }); ``` `activeUsers.execute({ input })` runs locally without HTTP, and `serve` exposes the same contract through a runtime. From Route 2: serve datasets and metrics [#from-route-2-serve-datasets-and-metrics] Pass your query builder plus the datasets and metrics you want to expose. Serve generates governed semantic endpoints from the same definitions. ```typescript // analytics/api.ts import { initServe } from '@hypequery/serve'; import { db } from './client.js'; import { Orders } from './datasets/orders.js'; import { revenue } from './metrics.js'; const { serve } = initServe({ context: () => ({ db }), basePath: '/api/analytics', }); export const api = serve({ queryBuilder: db, metrics: { revenue }, datasets: { orders: Orders }, }); ``` This generates `POST /metrics/revenue` for the named KPI and `POST /datasets/orders/query` for flexible same-dataset rollups, each prefixed by your `basePath`. Endpoints validate dimensions, measures, filters, time grains, and ordering against the definition. See [Serve integration](/docs/datasets/serve-integration) for per-endpoint auth, caching, and limits. Consume served datasets from React [#consume-served-datasets-from-react] Use `@hypequery/react` when a React or Next.js app should call those endpoints from components. Generate the route manifest as static JSON so the hooks module never imports server code into the client bundle: ```bash npx hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json ``` ```typescript import { createAnalyticsHooks } from '@hypequery/react'; import type { InferApiType } from '@hypequery/serve'; import type { api } from './analytics/api'; import manifest from './analytics/hypequery-manifest.json'; type Api = InferApiType; export const { useMetric, useDataset } = createAnalyticsHooks({ baseUrl: '/api/analytics', manifest, }); ``` The `api` import is type-only, so it is erased at build time; the manifest JSON is the only runtime dependency on the server module. For setup with `QueryClientProvider`, see the [React guide](/docs/react/getting-started). Preview docs and routes [#preview-docs-and-routes] Start the dev server against the serve file for the route you chose: ```bash # Route 1: named queries npx hypequery dev analytics/queries.ts # Route 2: datasets and metrics npx hypequery dev analytics/api.ts ``` With `basePath: '/api/analytics'`, the runtime exposes: * docs at `http://localhost:4000/api/analytics/docs` * OpenAPI at `http://localhost:4000/api/analytics/openapi.json` * your routes under `http://localhost:4000/api/analytics` --- # Rate Limiting (/docs/rate-limiting) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Rate Limiting [#rate-limiting] Use `rateLimit(...)` to protect your runtime from abuse. In the main `query + serve` path, you apply it as runtime middleware. Per-query use is still available on the builder-compatible `.use(...)` surface. Global rate limiting [#global-rate-limiting] Apply one policy to the whole runtime: ```typescript import { initServe, rateLimit } from '@hypequery/serve'; const { query, serve } = initServe({ context: () => ({ db }), middlewares: [ rateLimit({ windowMs: 60_000, max: 100, }), ], }); ``` This is a good default for public APIs and browser-facing runtimes. Per-query rate limiting [#per-query-rate-limiting] If you need rate limiting on one specific query, use the builder-compatible middleware surface: ```typescript const adminMetrics = query .use( rateLimit({ windowMs: 60_000, max: 20, }) ) .query(async ({ ctx }) => { return ctx.db.table('metrics').select('*').execute(); }); ``` Per-tenant or custom keys [#per-tenant-or-custom-keys] Use `keyBy` when the limit should be scoped by tenant, user, or some other request-derived identity: ```typescript const api = serve({ queries: { activeUsers }, middlewares: [ rateLimit({ windowMs: 60_000, max: 50, keyBy: (ctx) => ctx.auth?.tenantId ?? null, }), ], }); ``` If `keyBy` returns `null`, the request skips rate limiting. Options [#options] * `windowMs` sets the time window * `max` sets the maximum allowed hits in that window * `keyBy` controls how the rate-limit key is derived * `store` lets you use a custom backend instead of the in-memory store * `headers` enables or disables rate-limit headers on `429` responses * `message` customizes the 429 response message * `failOpen` controls whether store failures should skip limiting or fail the request Custom store [#custom-store] Use a custom `RateLimitStore` when you need a shared backend such as Redis: ```typescript import type { RateLimitStore } from '@hypequery/serve'; const redisStore: RateLimitStore = { increment: async (key, windowMs) => { // increment in Redis return 1; }, getTtl: async (key) => { // return remaining ttl return 60_000; }, reset: async (key) => { // clear stored counter }, }; ``` Semantic endpoints [#semantic-endpoints] Global rate limiting added through `initServe({ middlewares })` or `serve({ middlewares })` also covers auto-generated `metrics` and `datasets` endpoints — they run through the same middleware chain as queries. To limit a single metric or dataset endpoint, pass `rateLimit(...)` in that entry's `middlewares` array: ```typescript import { rateLimit } from '@hypequery/serve'; export const api = serve({ queryBuilder: db, datasets: { orders: { dataset: Orders, middlewares: [rateLimit({ windowMs: 60_000, max: 30 })], }, }, }); ``` See Also [#see-also] * [Authentication](/docs/authentication) * [Multi-Tenancy](/docs/multi-tenancy) * [Runtime Features](/docs/runtime-features) --- # Re-using Queries (/docs/re-using-queries) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; When to use [#when-to-use] Use `query({ ... })` when that builder logic needs to become a reusable application contract. That usually means you want one or more of these: * Typed input for callers * Output validation * Descriptions for docs and OpenAPI * Tags and summaries for grouped docs * Per-query auth and tenant rules * A stable definition you can execute locally and later mount in `serve({ queries })` `query({ ... })` does not replace the builder. It wraps builder logic so you can reuse it consistently. The distinction [#the-distinction] * `db.table(...)` builds and runs a query directly * `query({ ... })` turns that query into a reusable definition * `serve({ queries })` exposes those definitions through routes, docs, handlers, and runtime features Example: wrap builder logic as a reusable query [#example-wrap-builder-logic-as-a-reusable-query] Start by creating the shared runtime helpers:
    ```typescript
    import { initServe } from '@hypequery/serve';
    import { z } from 'zod';
    import { db } from './client';

    const { query, serve } = initServe({
      context: () => ({ db }),
      basePath: '/api/analytics',
    });
    ```
  
Then define a reusable query:
    ```typescript
    const activeUsers = query({
      description: 'Most recent active users',
      summary: 'List active users',
      tags: ['users'],
      requiredRoles: ['admin', 'editor'],
      input: z.object({
        limit: z.number().min(1).max(500).default(50),
      }),
      output: z.array(z.object({
        id: z.string(),
        email: z.string(),
        created_at: z.string(),
      })),
      query: ({ ctx, input }) =>
        ctx.db
          .table('users')
          .select(['id', 'email', 'created_at'])
          .where('status', 'eq', 'active')
          .orderBy('created_at', 'DESC')
          .limit(input.limit)
          .execute(),
    });
    ```
  
Inside `query({ ... })`, you still write normal builder code with `ctx.db.table(...)`. What query({ ... }) adds [#what-query---adds] Compared with a raw builder chain, `query({ ... })` adds: * `input` for validated caller input * `output` for validated response shape * `description` and `summary` for docs * `tags` for grouping * `requiresAuth`, `requiredRoles`, and `requiredScopes` for per-query access rules * `tenant` for per-query tenant overrides Those fields do not change how you write the query itself. They add metadata and validation around it. Reuse locally before HTTP [#reuse-locally-before-http] You can execute a query definition in-process before you ever add a route:
    ```typescript
    const rows = await activeUsers.execute({
      input: { limit: 25 },
    });
    ```
  
This is useful when multiple server-side parts of your app should share the same query contract without going over HTTP. Add serve({ queries }) later [#add-serve-queries--later] Once you have reusable definitions, you can expose them through the runtime:
    ```typescript
    export const api = serve({
      queries: { activeUsers },
    });

    api.route('/active-users', api.queries.activeUsers, { method: 'POST' });
    ```
  
The key in `queries: { activeUsers }` becomes the stable query name inside the exported API. If you need HTTP-specific behavior like `method`, add it when you route the query or in the runtime config. --- # Roadmap (/docs/roadmap) Open Roadmap → --- # Runtime Features (/docs/runtime-features) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Runtime Features [#runtime-features] Beyond routing, `serve({ queries })` gives you a runtime surface you can extend and inspect. api.use(middleware) [#apiusemiddleware] Use middleware to run shared logic around every request: ```typescript const timingMiddleware = async (ctx, next) => { const start = Date.now(); const result = await next(); console.log('durationMs', Date.now() - start); return result; }; api.use(timingMiddleware); ``` Use middleware for: * rate limiting * logging * extra validation * request shaping api.useAuth(strategy) [#apiuseauthstrategy] Add an auth strategy to the runtime after creation: ```typescript api.useAuth(async ({ request }) => { const token = request.headers.authorization; if (!token) return null; return verifyToken(token); }); ``` This is useful when the runtime is created before auth wiring is available. api.describe() [#apidescribe] Use `api.describe()` to inspect the runtime shape programmatically: ```typescript const description = api.describe(); console.log(description.queries); ``` `description.queries` lists every registered endpoint, including auto-generated semantic ones. Metric endpoints appear under their metric name and dataset endpoints under the `dataset:` key, each with its method, path, auth requirements, and contract metadata. This is useful for: * internal tooling * AI and agent surfaces * generated clients * runtime introspection in tests Semantic endpoints share the runtime [#semantic-endpoints-share-the-runtime] `metrics` and `datasets` registered with `serve({ ... })` flow through the same runtime as queries. Global middleware added with `api.use(...)` wraps them, `api.describe()` reports them, and `api.run('')` / `api.run('dataset:')` executes them in-process. To attach middleware to a single semantic endpoint, use the per-entry `middlewares` option instead of `api.use(...)`: ```typescript export const api = serve({ queryBuilder: db, metrics: { revenue: { metric: revenue, middlewares: [auditLog], }, }, }); ``` Observability belongs one layer over [#observability-belongs-one-layer-over] Hooks, query logging, and slow query detection are part of the same runtime surface, but they are covered in [Observability](/docs/observability) to keep this page focused on extension points. Use that page for: * `hooks.onRequestStart` * `hooks.onRequestEnd` * `hooks.onAuthFailure` * `hooks.onAuthorizationFailure` * `hooks.onError` * `queryLogging` * `slowQueryThreshold` See Also [#see-also] * [Observability](/docs/observability) * [Rate Limiting](/docs/rate-limiting) * [Serve Runtime Reference](/docs/reference/api/runtime) --- # Schemas (/docs/schemas) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Input/Output Schemas [#inputoutput-schemas] Use Zod schemas to validate incoming requests and document expected responses. This provides runtime validation, compile-time type safety, and automatic OpenAPI schema generation. Why Schemas? [#why-schemas] Schemas provide: * **Runtime validation** - Reject invalid requests before query execution * **Type safety** - Full TypeScript inference from Zod to your handlers * **Auto-documentation** - OpenAPI schemas generated automatically * **Error handling** - Detailed validation errors returned to clients * **AI agent compatibility** - JSON Schema for tool discovery Input Schemas [#input-schemas] Define what data your endpoint accepts:
    ```typescript
    import { initServe } from '@hypequery/serve';
    import { z } from 'zod';

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

    const revenue = query({
      input: z.object({
        startDate: z.string().datetime(),
        endDate: z.string().datetime(),
        currency: z.enum(['USD', 'EUR', 'GBP']).default('USD'),
        includeRefunds: z.boolean().optional(),
      }),
      query: async ({ ctx, input }) => {
        // input is fully typed based on the input schema
        const { startDate, endDate, currency } = input;

        return ctx.db
          .table('transactions')
          .where('date', 'gte', startDate)
          .where('date', 'lte', endDate)
          .where('currency', 'eq', currency)
          .sum('amount', 'total')
          .execute();
      },
    });

    export const api = serve({
      queries: { revenue },
    });
    ```
  
Validation Errors [#validation-errors] Invalid requests return detailed error messages: ```json { "error": { "type": "VALIDATION_ERROR", "message": "Request validation failed", "details": { "issues": [ { "code": "invalid_type", "expected": "string", "received": "number", "path": ["startDate"], "message": "Expected string, received number" } ] } } } ``` Output Schemas [#output-schemas] Document what your endpoint returns: Output schemas are optional for in-process usage. If you omit them, TypeScript infers the return type directly from your resolver and `api.run()` remains fully typed. However, without an `output`, OpenAPI docs, `api.describe()`, and external agents won't see structured response metadata. Add one whenever the query is exposed outside your codebase.
    ```typescript
    const revenue = query({
      input: z.object({
        startDate: z.string(),
        endDate: z.string(),
      }),
      output: z.object({
        total: z.number(),
        currency: z.string(),
        breakdown: z.array(z.object({
          date: z.string(),
          amount: z.number(),
          transactionCount: z.number(),
        })),
        metadata: z.object({
          generatedAt: z.string(),
          queryDurationMs: z.number(),
        }),
      }),
      query: async ({ input }) => {
        // Return value is type-checked against the output schema
        return {
          total: 42000,
          currency: 'USD',
          breakdown: [
            { date: '2025-01-01', amount: 10000, transactionCount: 50 },
            { date: '2025-01-02', amount: 12000, transactionCount: 60 },
          ],
          metadata: {
            generatedAt: new Date().toISOString(),
            queryDurationMs: 123,
          },
        };
      },
    });
    ```
  
Common Schema Patterns [#common-schema-patterns] Date Ranges [#date-ranges]
    ```typescript
    const dateRangeSchema = z.object({
      startDate: z.string().datetime(),
      endDate: z.string().datetime(),
    }).refine(
      (data) => new Date(data.startDate) < new Date(data.endDate),
      { message: 'startDate must be before endDate' }
    );

    queries: {
      metrics: query({
        input: dateRangeSchema,
        query: async ({ input }) => { /* ... */ },
      }),
    }
    ```
  
Unions and Discriminated Unions [#unions-and-discriminated-unions]
    ```typescript
    const reportSchema = z.discriminatedUnion('type', [
      z.object({
        type: z.literal('revenue'),
        currency: z.enum(['USD', 'EUR']),
        includeRefunds: z.boolean(),
      }),
      z.object({
        type: z.literal('users'),
        includeInactive: z.boolean(),
        segment: z.enum(['free', 'paid', 'enterprise']),
      }),
      z.object({
        type: z.literal('performance'),
        metric: z.enum(['latency', 'throughput', 'errors']),
        percentile: z.number().min(50).max(99),
      }),
    ]);

    queries: {
      generateReport: query({
        input: reportSchema,
        query: async ({ input }) => {
          switch (input.type) {
            case 'revenue':
              return generateRevenueReport(input.currency, input.includeRefunds);
            case 'users':
              return generateUserReport(input.includeInactive, input.segment);
            case 'performance':
              return generatePerformanceReport(input.metric, input.percentile);
          }
        },
      }),
    }
    ```
  
Reusable Schemas [#reusable-schemas] Define shared schemas once and reuse them:
    ```typescript
    // schemas/common.ts
    import { z } from 'zod';

    export const dateRangeSchema = z.object({
      startDate: z.string().datetime(),
      endDate: z.string().datetime(),
    });

    export const currencySchema = z.enum(['USD', 'EUR', 'GBP', 'JPY']);

    export const paginationInputSchema = z.object({
      page: z.number().int().positive().default(1),
      pageSize: z.number().int().min(1).max(100).default(20),
    });

    export const paginationOutputSchema = (dataSchema: T) =>
      z.object({
        data: z.array(dataSchema),
        pagination: z.object({
          page: z.number(),
          pageSize: z.number(),
          totalPages: z.number(),
          totalCount: z.number(),
        }),
      });

    // api/index.ts
    import { dateRangeSchema, currencySchema, paginationOutputSchema } from './schemas/common';

    const userSchema = z.object({
      id: z.string(),
      email: z.string(),
      name: z.string(),
    });

    const revenue = query({
      input: dateRangeSchema.extend({
        currency: currencySchema,
      }),
      query: async ({ input }) => { /* ... */ },
    });

    const users = query({
      input: paginationInputSchema,
      output: paginationOutputSchema(userSchema),
      query: async ({ input }) => { /* ... */ },
    });

    const api = serve({
      queries: { revenue, users },
    });
    ```
  
Type Inference [#type-inference] TypeScript automatically infers types from your schemas:
    ```typescript
    const userSchema = z.object({
      id: z.string(),
      email: z.string().email(),
      name: z.string(),
      age: z.number().int().positive().optional(),
    });

    type User = z.infer;
    // type User = {
    //   id: string;
    //   email: string;
    //   name: string;
    //   age?: number | undefined;
    // }

    const createUser = query({
      input: userSchema,
      output: userSchema.extend({
        createdAt: z.string().datetime(),
      }),
      query: async ({ input }) => {
        // input: User (fully typed!)
        const user = await db.table('users').insert(input).returning('*');

        return {
          ...user,
          createdAt: new Date().toISOString(),
        };
      },
    });
    ```
  
OpenAPI Generation [#openapi-generation] Schemas automatically generate OpenAPI documentation:
    ```typescript
    const revenue = query({
      input: z.object({
        startDate: z.string().datetime().describe('Start of date range (ISO 8601)'),
        endDate: z.string().datetime().describe('End of date range (ISO 8601)'),
        currency: z.enum(['USD', 'EUR']).default('USD').describe('Currency code'),
      }),
      output: z.object({
        total: z.number().describe('Total revenue in specified currency'),
        transactionCount: z.number().int().describe('Number of transactions'),
      }),
      summary: 'Get revenue for date range',
      description: 'Returns total revenue and transaction count',
      query: async ({ input }) => { /* ... */ },
    });

    // Auto-generates OpenAPI spec with detailed parameter descriptions
    ```
  
--- # Vite (/docs/vite) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Vite [#vite] This guide assumes you already have: * a Vite + React app * an `api/queries.ts` file exporting `api` * a running hypequery server process If not, start with [Quick Start](/docs/quick-start). Proxy API requests in development [#proxy-api-requests-in-development] Update `vite.config.ts`: ```typescript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { proxy: { '/api': { target: 'http://localhost:4000', changeOrigin: true, }, }, }, }); ``` Generate typed React hooks [#generate-typed-react-hooks] Create `src/lib/hypequery.ts`: ```typescript import { createHooks } from '@hypequery/react'; import type { ApiDefinition } from '../../api/queries'; export const { useQuery, useMutation } = createHooks({ baseUrl: '/api', }); ``` Wrap the app with QueryClientProvider [#wrap-the-app-with-queryclientprovider] ```tsx import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; const queryClient = new QueryClient(); createRoot(document.getElementById('root')!).render( ); ``` Use [Node.js](/docs/nodejs) or [Fetch Runtime Integration](/docs/fetch) for the server process that hosts `api.handler`. --- # Why hypequery (/docs/why-hypequery) The problem [#the-problem] Analytics logic often ends up spread across API routes, background jobs, dashboards, notebooks, and internal tools. That creates a few predictable problems: * the same metric gets reimplemented in slightly different ways * changes require updating SQL in multiple places * there is no single source of truth for analytics logic * type safety usually stops at the application boundary and does not extend into analytics queries * analytics definitions are harder to review, version, and evolve with confidence Semantic layers try to solve this, but often introduce a new system to learn, deploy, and keep in sync with your application. The idea [#the-idea] hypequery takes a different approach: analytics should live in your application code. Not in a separate platform. Not in YAML configs. Not in dashboards. If your backend is TypeScript, your analytics layer should be too. What hypequery is [#what-hypequery-is] hypequery is a ClickHouse-first, TypeScript-native semantic layer that runs inside your application. It starts as a typed query builder and scales into reusable query definitions and optional runtime delivery — without forcing you into a separate system. With hypequery, you can: build typed ClickHouse queries directly in your codebase define reusable metrics and dimensions as stable contracts expose those contracts via APIs, docs, and auth — only when needed All without introducing another service to deploy or maintain. What hypequery is not hypequery is intentionally narrow in scope: not an ORM not a generic multi-database abstraction not a hosted platform It is designed specifically for ClickHouse analytics in TypeScript applications. When hypequery fits well [#when-hypequery-fits-well] Use hypequery when: * your analytics database is ClickHouse * your application code is in TypeScript * analytics queries need to live in more than one place * you want stronger type safety around query construction and query interfaces * you want analytics logic in normal version-controlled application code * you want HTTP exposure, docs, or runtime features to stay optional When hypequery is a poor fit [#when-hypequery-is-a-poor-fit] hypequery is probably the wrong tool when: * you do not use ClickHouse * you only need ad hoc SQL exploration * you want a GUI-based semantic layer instead of code * you need a managed hosted analytics platform How it compares [#how-it-compares] Versus direct SQL [#versus-direct-sql] hypequery is a better fit when the same query needs reuse, type safety, reviewability, and a stable interface. Raw SQL is still fine for one-off exploration or very local query logic. Versus generic query builders [#versus-generic-query-builders] Generic query builders are better for broad CRUD and multi-database work. hypequery is better when the problem is specifically ClickHouse analytics and you want ClickHouse-first behavior rather than a lowest-common-denominator abstraction. Versus semantic layers [#versus-semantic-layers] Semantic layers are a better fit when you need a broader cross-tool or cross-database platform. hypequery is a better fit when you want application-embedded analytics in TypeScript without adopting a separate config-heavy system. The core idea [#the-core-idea] hypequery keeps analytics close to application code: * define queries in TypeScript * reuse them where they matter * add runtime delivery only when you actually need it Read [Core Concepts](/docs/core-concepts) for the builder, reusable query definition, and runtime layers. --- # Caching (/docs/datasets/caching) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; The dataset client can cache query results so repeated semantic queries do not hit ClickHouse. Results are keyed by the full query signature — target, dimensions, measures, filters, ordering, pagination, time grain, tenant scope, and any explicit cache scope — so two different queries never share an entry, and tenant-scoped datasets are partitioned per tenant. This is the semantic-layer cache in `@hypequery/datasets`, keyed by what a query *means*. It is independent of [query caching](/docs/query-building/caching) in `@hypequery/clickhouse`, which caches raw `execute()` results on the typed query builder. Enable caching on the client [#enable-caching-on-the-client] Pass `cache` to `createDatasetClient` to set a default TTL for every query. ```typescript import { createDatasetClient } from '@hypequery/datasets'; import { createQueryBuilder } from '@hypequery/clickhouse'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const analytics = createDatasetClient({ queryBuilder: db, cache: { ttlMs: 60_000, staleWhileRevalidateMs: 300_000, }, }); ``` * `ttlMs` — how long a result is served as fresh. * `staleWhileRevalidateMs` — an optional window after the TTL during which the stale result is returned immediately while a background refresh repopulates the entry. * Errors are never cached, and concurrent identical queries share a single execution. Per-call overrides [#per-call-overrides] Every `execute` call can override or bypass the cache through the execution context. ```typescript // Opt a single call into caching (works even without client-level cache config). await analytics.execute(revenue, { dimensions: ['country'] }, { cache: { ttlMs: 30_000 }, }); // Bypass the cache for one call. await analytics.execute(revenue, { dimensions: ['country'] }, { cache: false, }); // Skip the read but store the fresh result (force refresh). await analytics.execute(revenue, { dimensions: ['country'] }, { cache: { mode: 'refresh' }, }); ``` `mode: 'refresh'` needs a TTL to write under — either a per-call `ttlMs` or a client-level `cache.ttlMs`. If neither is configured the call executes uncached and logs a one-time warning, so a config drift never fails requests but does not go undiagnosed. A refresh always runs its own execution, even when an identical query is already in flight. Cache metadata [#cache-metadata] Results that went through the cache carry `meta.cache`. ```typescript const result = await analytics.execute(revenue, { dimensions: ['country'], }, { cache: { ttlMs: 60_000 }, }); result.meta?.cache; // { hit: true, ageMs: 1200 } — served fresh from the cache // { hit: true, ageMs: 65000, stale: true } — served stale, refreshing in background // { hit: false } — executed and stored ``` Serve endpoints [#serve-endpoints] Metric and dataset endpoints registered with a `cache` value cache results server-side with that TTL, in addition to emitting `Cache-Control` headers. ```typescript export const api = serve({ queryBuilder: db, metrics: { revenue: { metric: revenue, cache: 60_000, }, }, datasets: { orders: { dataset: Orders, cache: 60_000, }, }, }); ``` Repeated identical requests within the TTL are served from the cache without querying ClickHouse. Because tenant scope is part of the cache key, runtime tenancy stays isolated: each tenant only ever sees entries for its own scope. Custom stores [#custom-stores] The default store is an in-process LRU (500 entries). For multi-instance deployments, provide a shared store implementing `SemanticCacheStore` — three methods, sync or async. Here is a Redis-backed store using a Redis client such as [ioredis](https://github.com/redis/ioredis): ```typescript import { createDatasetClient, type SemanticCacheEntry, type SemanticCacheStore, } from '@hypequery/datasets'; interface RedisLike { get(key: string): Promise; set(key: string, value: string, mode: 'PX', ttlMs: number): Promise; del(key: string): Promise; } declare const redis: RedisLike; const TTL_MS = 60_000; const SWR_MS = 300_000; const redisStore: SemanticCacheStore = { async get(key): Promise { const raw = await redis.get(key); return raw ? JSON.parse(raw) : undefined; }, async set(key, entry) { // Expire in Redis once the entry can never be served again. Freshness // within that window is decided by the client from `storedAt`, so the // Redis expiry is only garbage collection. await redis.set(key, JSON.stringify(entry), 'PX', TTL_MS + SWR_MS); }, async delete(key) { await redis.del(key); }, }; const cachedAnalytics = createDatasetClient({ queryBuilder: db, cache: { ttlMs: TTL_MS, staleWhileRevalidateMs: SWR_MS, store: redisStore, }, }); ``` Behavior to rely on when writing a store: * Entries are opaque `{ value, storedAt }` objects; freshness is always decided by the client from `storedAt` and the effective TTL, so per-call TTL overrides work against shared entries. Give the store-side expiry (Redis `PX` above) at least `ttlMs + staleWhileRevalidateMs`. * Store failures are non-fatal: a failed `get` is treated as a cache miss and a failed `set` is dropped, so a Redis outage degrades to "no caching" instead of failing queries. * Concurrent identical queries are deduplicated per process even while an async `get` is in flight — a burst of the same query does one store read and at most one execution per instance. * Keys are readable canonical signatures and can get long; stores are free to hash them internally (e.g. SHA-256) as long as the mapping is stable. * Values are plain rows plus `meta` and survive `JSON.stringify` round-trips. Cache scopes [#cache-scopes] A cache key describes the query, not the connection it runs against. When the same semantic query can resolve against different data sources, partition entries with `scope`: ```typescript import { createDatasetClient, type QueryBuilderFactoryLike, type SemanticCacheStore, } from '@hypequery/datasets'; declare const euDb: QueryBuilderFactoryLike; declare const replicaDb: QueryBuilderFactoryLike; declare const redisStore: SemanticCacheStore; // Client-level: namespace clients that share one store (e.g. one Redis // serving several warehouses), so identical queries never collide. const euAnalytics = createDatasetClient({ queryBuilder: euDb, cache: { ttlMs: 60_000, store: redisStore, scope: 'warehouse-eu' }, }); // Per-call: required when overriding the query builder at runtime. Without a // scope, calls that pass `runtime.builderFactory` skip the cache entirely, // because the key alone cannot tell two data sources apart. await analytics.execute(revenue, { dimensions: ['country'] }, { runtime: { builderFactory: replicaDb }, cache: { scope: 'replica-2' }, }); ``` --- # Catalog (/docs/datasets/catalog) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; The dataset catalog is a normalized metadata view of a dataset definition. Use it when another runtime needs to inspect the semantic model without reaching into dataset internals. Catalog metadata includes: * source table or view * tenant and time keys * dimensions and their labels, descriptions, columns, SQL expressions, and filter/group flags * measures and their aggregation metadata * attached named metrics * filters, allowed operators, and filter value types * relationship metadata, including whether each relationship is queryable and the qualified field names it contributes * dataset limits, maximum result limit, supported time grains, and orderable fields Dataset catalog [#dataset-catalog] ```typescript import { getDatasetCatalog } from '@hypequery/datasets'; import { Orders } from './datasets/orders.js'; const catalog = getDatasetCatalog(Orders); console.log(catalog.dimensions.status); console.log(catalog.measures.revenue); ``` The returned object is plain JSON-compatible metadata. ```json { "name": "orders", "source": "orders", "tenantKey": "tenant_id", "timeKey": "created_at", "dimensions": { "status": { "type": "string", "column": "status", "label": "Status", "filterable": true, "groupable": true } }, "measures": { "revenue": { "aggregation": "sum", "field": "amount", "label": "Revenue", "filterCount": 0 } }, "metrics": {}, "filters": { "status": { "field": "status", "operators": ["eq", "in"], "valueType": "string" } }, "relationships": {}, "limits": { "maxMeasures": 3, "maxResultSize": 1000 }, "requiresTenant": true, "supportedGrains": ["day", "week", "month", "quarter", "year"], "orderableFields": ["status", "revenue", "period"], "maxLimit": 1000 } ``` The full catalog above is a trusted developer/debug view: it intentionally contains physical source, column, SQL, and tenancy details. Do not place it in model context. Use `projectAgentSafeCatalog()` for agent discovery; it removes those physical details, hides dimensions that are neither filterable nor groupable, and enforces a UTF-8 byte budget over the complete result. ```typescript import { projectAgentSafeCatalog } from '@hypequery/datasets'; const safeCatalog = projectAgentSafeCatalog( { orders: Orders }, { maxCatalogBytes: 128 * 1024 }, ); ``` For an authenticated support surface, `projectTrustedDebugCatalog()` accepts an explicit `{ authorized: true }` proof supplied only after the owning application has made its authorization decision. The trusted projection is not registered as an agent-facing MCP tool. Agent metadata [#agent-metadata] Datasets, dimensions, measures, filters, and metrics accept optional `examples`, `synonyms`, `format`, `unit`, `currency`, `timezone`, and `sensitivity` metadata. Datasets also accept a description, freshness expectation, owner, and safe query defaults: ```typescript const Orders = dataset('orders', { source: 'analytics.orders', description: 'Governed order analytics.', examples: ['Revenue by region', 'Weekly order volume'], synonyms: ['purchases'], timezone: 'UTC', freshness: { maxAgeSeconds: 300 }, owner: 'analytics@example.com', sensitivity: 'internal', defaults: { dimensions: ['region'], timeGrain: 'week' }, timeKey: 'createdAt', dimensions: { createdAt: dimension.timestamp({ format: 'date-time', timezone: 'UTC' }), region: dimension.string({ examples: ['EMEA', 'NA'], synonyms: ['market'] }), }, }); ``` Metadata is validated and size-bounded when defined, normalized into stable ordering, and carried through semantic contracts and portable deployment contracts. Verified question/answer fixtures are intentionally a separate API. `sensitivity` is an advisory label, not an access control. A field marked `confidential` or `restricted` is still listed in the agent-safe catalog and is still queryable: nothing filters a catalog or authorizes a query based on this value. Use it to inform reviewers and downstream tooling, and keep deciding what to publish — and who may query it — in your dataset registry and endpoint policy. Relationship entries carry `queryable` and `fields` — the qualified `.` names a one-hop query may reference. To-one relationships (`belongsTo`, `hasOne`) are queryable; `hasMany` entries report `queryable: false` with an empty field list. See [Relationships](/docs/datasets/relationships) for the query semantics. When a filter definition does not specify `operators`, the catalog exposes the default semantic operators from `SEMANTIC_FILTER_OPERATORS`: ```typescript import { SEMANTIC_FILTER_OPERATORS } from '@hypequery/datasets'; console.log(SEMANTIC_FILTER_OPERATORS); // ["eq", "neq", "gt", "gte", "lt", "lte", "in", "notIn", "between", "like"] ``` Catalog maps [#catalog-maps] Use `getDatasetCatalogs` when you already have a dataset registry. ```typescript import { getDatasetCatalogs } from '@hypequery/datasets'; import { Customers, Orders } from './datasets/index.js'; const catalogs = getDatasetCatalogs({ orders: Orders, customers: Customers, }); ``` Measures vs metrics [#measures-vs-metrics] Measures are raw aggregations available in dataset queries. Metrics are named KPI handles created with `dataset.metric(...)`. By default, a dataset instance has measures but does not store every metric ref created from it. Publish named metric refs with a dataset to include them in the catalog. ```typescript import { getDatasetCatalog, publishDatasets } from '@hypequery/datasets'; const revenue = Orders.metric('revenue', { measure: 'revenue', label: 'Revenue', }); const datasets = publishDatasets() .publish(Orders, { metrics: { revenue } }) .build(); const catalog = getDatasetCatalog(datasets.orders); console.log(catalog.measures.revenue); console.log(catalog.metrics.revenue); ``` `build()` returns the same plain registry shape used by the MCP server. It lets `get_dataset_schema` show both the exploratory dataset measures and the named metrics that agents should use for stable KPIs. Existing hand-built registry objects remain supported for compatibility. Current consumers [#current-consumers] The catalog is public in `@hypequery/datasets`. MCP introspection, MCP dataset listing, Serve endpoint descriptions, Serve OpenAPI input schemas, and generated dataset tools use it today to keep metadata aligned with real dataset definitions. React metadata, CLI-generated labels, and broader cross-package drift tests are planned follow-up work. For agent and function-calling schemas built from the same catalog, see [Tool Generation](/docs/datasets/tool-generation). --- # Definition (/docs/datasets/defining-datasets) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; A dataset is a typed semantic model over a source table or view. ```typescript import { dataset, dimension, measure } from '@hypequery/datasets'; const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { id: dimension.number(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), }, }); ``` `source` is the physical table or view name. The first `dataset()` argument is the logical dataset name. `tenantKey` and `timeKey` are physical column names used for runtime tenant isolation and time graining. Module pattern [#module-pattern] Define datasets at the module level and export them from dedicated files. ```typescript // datasets/orders.ts export const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { id: dimension.number(), status: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), }, }); ``` Public shape [#public-shape] A dataset can include: * `source` * `tenantKey` * `timeKey` * `dimensions` * `measures` * `filters` * `relationships` * `limits` See [Dimensions](/docs/datasets/dimensions), [Measures](/docs/datasets/measures), [Metrics](/docs/datasets/metrics), and [Relationships](/docs/datasets/relationships) for the main building blocks. --- # Dimensions (/docs/datasets/dimensions) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Dimensions are the non-aggregated fields in a dataset. They are the fields callers can select, group by, filter on, and order by. The object key is the semantic API name. The dimension helper tells hypequery the field type, and the options tell hypequery how that semantic field maps to ClickHouse. ```typescript dimensions: { id: dimension.string(), status: dimension.string({ label: 'Status' }), isTrial: dimension.boolean({ column: 'is_trial' }), createdAt: dimension.timestamp({ column: 'created_at' }), } ``` With that definition, callers use the semantic names: ```typescript await analytics.execute(Orders, { dimensions: ['status', 'createdAt'], measures: ['revenue'], }); ``` The generated SQL still uses the mapped ClickHouse columns, such as `created_at`. Dimension helpers [#dimension-helpers] * `dimension.string(opts?)` * `dimension.number(opts?)` * `dimension.boolean(opts?)` * `dimension.timestamp(opts?)` The helper should match the value type of the field. That type is used for query validation, generated contracts, and integration surfaces. These are semantic dimension types, not a full mirror of every ClickHouse physical type. Use the closest analytical type: | ClickHouse types | Dimension type | | -------------------------------------------------- | ----------------------- | | `String`, `LowCardinality(String)`, `Enum`, `UUID` | `dimension.string()` | | `UInt64`, `Int32`, `Float64`, `Decimal` | `dimension.number()` | | `Bool` or boolean-style `UInt8` flags | `dimension.boolean()` | | `Date`, `DateTime`, `DateTime64` | `dimension.timestamp()` | The query builder can still use precise generated ClickHouse schema types. Datasets expose a smaller semantic model for analytics, validation, docs, and integrations. Options [#options] Every dimension helper accepts the same options object: ```typescript type DimensionOptions = { label?: string; description?: string; column?: string; sql?: string; filterable?: boolean; groupable?: boolean; }; ``` | Option | Use it for | | ------------- | --------------------------------------------------------------------------------------------------------- | | `column` | Mapping a semantic field name to a physical ClickHouse column | | `sql` | Backing a dimension with a SQL expression instead of a simple column | | `label` | Human-readable display text for docs, UIs, and agents | | `description` | Longer metadata for generated docs, UIs, and agents | | `filterable` | Whether callers should be allowed to filter on the dimension. Defaults to `true` | | `groupable` | Whether callers should be allowed to group/select the dimension. Defaults to `true` in the semantic model | Column mapping [#column-mapping] Use `opts.column` when the semantic field name differs from the physical column name. ```typescript dimensions: { userId: dimension.string({ column: 'user_id' }), isActive: dimension.boolean({ column: 'is_active' }), createdAt: dimension.timestamp({ column: 'created_at' }), } ``` This keeps your public dataset API idiomatic while still targeting the real table columns: ```typescript await analytics.execute(Users, { dimensions: ['userId', 'createdAt'], }); ``` Labels and descriptions [#labels-and-descriptions] Use `label` and `description` for human-readable metadata. These do not change SQL generation, but they are exposed through generated docs and MCP schema introspection so agents and UIs can understand what a field means. ```typescript dimensions: { status: dimension.string({ label: 'Order Status', description: 'Current lifecycle state for the order.', }), } ``` For MCP, this metadata becomes part of `get_dataset_schema`, which helps agents choose the right fields without guessing from column names alone. SQL-backed dimensions [#sql-backed-dimensions] Use `sql` when the dimension is computed rather than stored as a single column. ```typescript dimensions: { countryCode: dimension.string({ column: 'country_code' }), countryUpper: dimension.string({ sql: 'upper(country_code)' }), } ``` Use SQL-backed dimensions sparingly. Schema compatibility checks can inspect simple column references, but complex SQL expressions can only produce warnings because they cannot be fully verified from the table schema. The `sql` option is authored in your dataset definition. Runtime callers should only reference the semantic dimension name, not provide SQL. See [Trust Boundaries](/docs/reference/trust-boundaries). Filterable and groupable [#filterable-and-groupable] Use `filterable` and `groupable` when a field exists in the dataset contract but should not be available for every query operation. ```typescript dimensions: { email: dimension.string({ filterable: true, groupable: false, description: 'Allowed in filters, but not useful as a grouping key.', }), } ``` Most dimensions can omit these options. By default, dimensions are available for filtering and grouping. Set `filterable: false` to remove a dimension from the automatically generated filter set. `groupable` records the intended grouping behavior on the dimension definition. Use it to communicate stricter query intent to generated docs, UIs, or integration layers. --- # Execution (/docs/datasets/execution) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; `createDatasetClient` is the execution surface for datasets and metrics. In a ClickHouse app, pass the same query builder instance you use for hand-written queries. ```typescript import { createDatasetClient } from '@hypequery/datasets'; import { createQueryBuilder } from '@hypequery/clickhouse'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const analytics = createDatasetClient({ queryBuilder: db }); ``` Use `db.table(...)` for local typed queries and `analytics.execute(...)` for semantic dataset or metric queries. Both paths share the same ClickHouse connection. Execution methods [#execution-methods] The dataset client exposes three main methods: | Method | Use it for | | --------------------------------------------- | ------------------------------------------------- | | `analytics.validate(target, query, context?)` | Check a query without generating or executing SQL | | `analytics.toSQL(target, query, context?)` | Inspect generated SQL without executing it | | `analytics.execute(target, query, context?)` | Validate, generate SQL, execute, and return rows | The `target` can be a metric, a grained metric, or a dataset. Validate [#validate] Use `validate` when you want errors as data instead of exceptions. ```typescript const validation = analytics.validate(revenue, { dimensions: ['country'], filters: [eq('status', 'completed')], }); if (!validation.valid) { console.error(validation.errors); } ``` Validation checks dimensions, measures, filters, order fields, limits, time grain requirements, tenant context, and derived metric plans. Generate SQL [#generate-sql] Use `toSQL` to inspect the generated query. ```typescript const sql = analytics.toSQL(revenue, { dimensions: ['country'], filters: [eq('status', 'completed')], orderBy: [{ field: 'revenue', direction: 'desc' }], limit: 10, }); ``` `toSQL` validates first. Invalid queries throw with the same validation errors that `execute` would throw. Execute a metric [#execute-a-metric] Metric execution returns one named KPI, optionally grouped by dimensions or time. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue' }); const result = await analytics.execute(revenue, { dimensions: ['country'], filters: [eq('status', 'completed')], orderBy: [{ field: 'revenue', direction: 'desc' }], limit: 10, }); ``` Use `by` for time bucketing. It is a flat grain value, not an object: ```typescript const result = await analytics.execute(revenue, { by: 'month', dimensions: ['country'], }); ``` Execute a dataset [#execute-a-dataset] Dataset execution returns an ad hoc selection of dimensions and measures from one dataset. ```typescript const result = await analytics.execute(Orders, { dimensions: ['country', 'status'], measures: ['revenue', 'orderCount'], filters: [eq('status', 'completed')], limit: 10, }); ``` Dataset queries use the same `by` field when the dataset declares a `timeKey`: ```typescript const result = await analytics.execute(Orders, { by: 'day', dimensions: ['status'], measures: ['revenue', 'orderCount'], }); ``` Execution context [#execution-context] Pass execution context as the third argument. This is where runtime tenant scope and per-call builder overrides live. ```typescript await analytics.execute(revenue, { dimensions: ['country'], }, { runtime: { tenant: 'tenant_123', }, }); ``` When a dataset has `tenantKey`, tenant context is required unless you explicitly use a trusted cross-tenant scope. Datasets auto-inject the tenant filter from `tenantKey`. Result metadata [#result-metadata] `execute` returns rows plus metadata. ```typescript const result = await analytics.execute(revenue, { dimensions: ['country'], limit: 25, }); result.data; result.meta?.sql; result.meta?.timingMs; result.meta?.rowCount; result.meta?.pagination; ``` Pagination metadata is present when a query sets `limit`. Hypequery over-fetches one extra row to compute `hasMore` without issuing a separate count query. ```typescript { limit: 25, offset: 0, hasMore: true, } ``` --- # Filters (/docs/datasets/filters) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Filters are structured query objects. They are used by metric queries, dataset queries, and filtered measures. ```typescript import { between, eq, gt, gte, inList, like, lt, lte, neq, notInList, } from '@hypequery/datasets'; ``` Filter helpers [#filter-helpers] ```typescript eq('status', 'completed') neq('status', 'cancelled') gt('amount', 100) gte('amount', 100) lt('amount', 1000) lte('amount', 1000) inList('country', ['US', 'UK', 'CA']) notInList('status', ['cancelled', 'refunded']) between('createdAt', '2026-01-01', '2026-01-31') like('email', '%@example.com') ``` Each helper returns a filter with a field, operator, and value. ```typescript eq('status', 'completed') // { field: 'status', operator: 'eq', value: 'completed' } ``` Use filters in metric queries [#use-filters-in-metric-queries] ```typescript await analytics.execute(revenue, { dimensions: ['country'], filters: [ eq('status', 'completed'), gte('amount', 100), inList('country', ['US', 'UK', 'CA']), ], }); ``` Use filters in dataset queries [#use-filters-in-dataset-queries] ```typescript await analytics.execute(Orders, { dimensions: ['country', 'status'], measures: ['revenue', 'orderCount'], filters: [ eq('status', 'completed'), between('createdAt', '2026-01-01', '2026-01-31'), ], }); ``` Use filters in measures [#use-filters-in-measures] Filtered measures are useful when a business state should always be part of the aggregation. ```typescript import { dataset, dimension, eq, measure } from '@hypequery/datasets'; const Orders = dataset('orders', { source: 'orders', dimensions: { status: dimension.string(), }, measures: { completedRevenue: measure.sum('amount', { filters: [eq('status', 'completed')], }), refundedRevenue: measure.sum('amount', { filters: [eq('status', 'refunded')], }), }, }); ``` Restrict filter operators [#restrict-filter-operators] By default, filterable dimensions become available as filters. Use the dataset `filters` map when you want a named filter contract or a restricted operator set. ```typescript import { dataset, dimension, measure } from '@hypequery/datasets'; const Orders = dataset('orders', { source: 'orders', dimensions: { status: dimension.string(), country: dimension.string(), }, measures: { revenue: measure.sum('amount'), }, filters: { status: { __type: 'filter_definition', field: 'status', operators: ['eq', 'neq', 'in', 'notIn'], }, }, }); ``` With this contract, `status` is the only exposed filter field, and only the listed operators are allowed. Order helpers [#order-helpers] `asc` and `desc` create structured order clauses. ```typescript import { asc, desc } from '@hypequery/datasets'; await analytics.execute(revenue, { dimensions: ['country'], orderBy: [desc('revenue'), asc('country')], }); ``` Tenant filters [#tenant-filters] When runtime tenancy is active, explicit filters on the tenant field are rejected. Use runtime tenant context instead. ```typescript await analytics.execute(revenue, { filters: [eq('tenantId', 'tenant_123')], }, { runtime: { tenant: 'tenant_123', }, }); // Error: Cannot filter on tenant field "tenantId" when runtime tenancy enforcement is active. ``` --- # Measures (/docs/datasets/measures) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Measures are aggregations over fields in a dataset. Callers can request values such as revenue, order count, average order amount, minimum value, or maximum value. Measure and metric result columns are returned as `string | null`: every non-null aggregate is normalized to a string across ClickHouse result types and execution backends, while SQL `NULL` remains `null`. Convert numeric values at the application boundary when needed. The object key is the semantic API name. The first argument is the backing field to aggregate. If that field is a dimension, hypequery uses the dimension mapping to resolve the ClickHouse column. ```typescript measures: { revenue: measure.sum('amount', { label: 'Revenue', description: 'Total order amount before refunds.', }), orderCount: measure.count('id'), uniqueCustomers: measure.countDistinct('customerId'), averageAmount: measure.avg('amount'), minAmount: measure.min('amount'), maxAmount: measure.max('amount'), } ``` With that definition, callers use the semantic measure names: ```typescript await analytics.execute(Orders, { dimensions: ['country'], measures: ['revenue', 'orderCount'], }); ``` Aggregation helpers [#aggregation-helpers] * `measure.sum(field, opts?)` * `measure.count(field, opts?)` * `measure.countDistinct(field, opts?)` * `measure.avg(field, opts?)` * `measure.min(field, opts?)` * `measure.max(field, opts?)` * `measure.percentile(field, level, opts?)` * `measure.median(field, opts?)` * `measure.argMax(field, by, opts?)` * `measure.argMin(field, by, opts?)` * `measure.stddev(field, opts?)` * `measure.variance(field, opts?)` The helper determines the aggregation. The field should be the semantic field name when it exists in `dimensions`, or the physical column name when you are aggregating a raw table column. `percentile` uses ClickHouse's approximate `quantile(level)` aggregation, where `level` is between `0` and `1`. `median` is shorthand for `percentile(field, 0.5)`. `stddev` and `variance` use sample statistics (`stddevSamp` and `varSamp`). `argMax` and `argMin` return the value of `field` from the row where `by` is greatest or smallest. The `by` field can use a semantic dimension name, so column mappings are respected: ```typescript measures: { p95Latency: measure.percentile('latencyMs', 0.95), medianLatency: measure.median('latencyMs'), latestStatus: measure.argMax('status', 'createdAt'), firstStatus: measure.argMin('status', 'createdAt'), latencyStddev: measure.stddev('latencyMs'), latencyVariance: measure.variance('latencyMs'), } ``` The percentile, standard deviation, and variance helpers support measure-local filters. Filters are intentionally not supported on `argMax` or `argMin` because NULL handling for conditional arg aggregates differs across ClickHouse versions. Use a query-level filter when filtering an arg aggregate. Options [#options] Most measure helpers accept the following options object. `argMax` and `argMin` accept `sql`, `label`, and `description`, but not `filters`. ```typescript type MeasureOptions = { sql?: string; label?: string; description?: string; filters?: MetricFilter[]; }; ``` | Option | Use it for | | ------------- | ----------------------------------------------------------------- | | `sql` | Aggregating a SQL expression instead of a simple field | | `label` | Human-readable display text for docs, UIs, metrics, and agents | | `description` | Longer metadata for generated docs, UIs, metrics, and agents | | `filters` | Applying measure-local filters before the aggregation is computed | Field mapping [#field-mapping] Measures reference fields by name. When the field is a dimension with a `column` mapping, the measure follows that mapping. ```typescript dimensions: { customerId: dimension.string({ column: 'customer_id' }), amount: dimension.number(), }, measures: { uniqueCustomers: measure.countDistinct('customerId'), revenue: measure.sum('amount'), } ``` In this example, callers use `uniqueCustomers`, and the generated SQL counts distinct `customer_id`. Labels and descriptions [#labels-and-descriptions] Use `label` and `description` for human-readable metadata. These do not change SQL generation. ```typescript measures: { revenue: measure.sum('amount', { label: 'Revenue', description: 'Total order amount before refunds.', }), } ``` When you promote a measure into a base metric, the metric inherits the measure label and description unless you override them on the metric. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue' }); ``` For MCP, measures are exposed through `get_dataset_schema` for ad hoc dataset queries. When you promote a measure into a named metric, the metric can inherit the measure label and description for agent-facing KPI queries. SQL-backed measures [#sql-backed-measures] Use `sql` when the aggregation should operate on an expression rather than a simple field. ```typescript measures: { taxedRevenue: measure.sum('amount', { sql: 'amount * 1.2', }), } ``` The `field` argument still names the logical backing field, while `sql` provides the expression to aggregate. Use SQL-backed measures sparingly. Schema compatibility checks can inspect simple fields, but complex SQL expressions can only produce warnings because they cannot be fully verified from the table schema. SQL-backed measures require builder-backed execution. Generic semantic backend plans reject SQL-backed measures because cross-backend adapters cannot assume the same SQL expression syntax. The `sql` option is authored in your dataset definition. Runtime callers should only request the semantic measure name, not provide SQL. See [Trust Boundaries](/docs/reference/trust-boundaries). Filtered measures [#filtered-measures] Use filtered measures for common business states instead of repeating the same filters in every query. ```typescript import { dataset, dimension, eq, measure } from '@hypequery/datasets'; const Orders = dataset('orders', { source: 'orders', dimensions: { status: dimension.string(), }, measures: { completedRevenue: measure.sum('amount', { filters: [eq('status', 'completed')], }), refundedRevenue: measure.sum('amount', { filters: [eq('status', 'refunded')], }), }, }); ``` --- # Metrics (/docs/datasets/metrics) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Metrics are named KPI handles attached to a dataset. They are the reusable values you expose to APIs, dashboards, agents, and MCP clients. Measures define raw aggregations inside a dataset. Metrics promote those measures into named business concepts. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue' }); await analytics.execute(revenue, { dimensions: ['country'], filters: [eq('status', 'completed')], limit: 10, }); ``` Base metrics [#base-metrics] Base metrics point at one measure from the same dataset. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue', label: 'Total Revenue', description: 'Total order amount before refunds.', }); const orderCount = Orders.metric('orderCount', { measure: 'orderCount', label: 'Order Count', }); ``` If `label` or `description` are omitted, the metric inherits them from the referenced measure when available. Base metric options [#base-metric-options] ```typescript type BaseMetricConfig = { measure: string; label?: string; description?: string; }; ``` | Option | Use it for | | ------------- | ----------------------------------------------------- | | `measure` | The dataset measure this metric exposes | | `label` | Human-readable display text for docs, UIs, and agents | | `description` | Longer metadata for generated docs, UIs, and agents | Derived metrics [#derived-metrics] Derived metrics compose base metrics from the same dataset using formulas. ```typescript import { divide, nullIfZero } from '@hypequery/datasets'; const averageOrderValue = Orders.metric('averageOrderValue', { uses: { revenue, orderCount }, formula: ({ revenue, orderCount }) => divide(revenue, nullIfZero(orderCount)), label: 'Average Order Value', }); ``` Derived metrics can only use base metrics from the same dataset. They cannot reference metrics from another dataset, and they cannot be derived from another derived metric. Derived metric options [#derived-metric-options] ```typescript type DerivedMetricConfig = { uses: Record; formula: (inputs: Record) => FormulaExpr; label?: string; description?: string; }; ``` | Option | Use it for | | ------------- | ----------------------------------------------------- | | `uses` | The base metric refs available to the formula | | `formula` | A symbolic formula that combines those base metrics | | `label` | Human-readable display text for docs, UIs, and agents | | `description` | Longer metadata for generated docs, UIs, and agents | Formula helpers include: * `divide(a, b)` * `multiply(a, b)` * `add(a, b)` * `subtract(a, b)` * `nullIfZero(value)` * `coalesce(...)` * `round(...)` Formula helpers are symbolic. They build expressions that hypequery can plan and execute; they are not raw SQL strings. Metric queries vs dataset queries [#metric-queries-vs-dataset-queries] Use metric queries when you want to expose one named KPI contract. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue' }); await analytics.execute(revenue, { dimensions: ['country'], filters: [eq('status', 'completed')], orderBy: [{ field: 'revenue', direction: 'desc' }], limit: 10, }); ``` Use dataset queries when callers need an ad hoc selection of dimensions and measures from the same dataset. ```typescript await analytics.execute(Orders, { dimensions: ['country', 'status'], measures: ['revenue', 'orderCount'], filters: [eq('status', 'completed')], }); ``` Both paths use the same dataset definition and validation rules. Metrics are better for reusable product concepts; dataset queries are better for same-dataset exploration. Time grains [#time-grains] When the dataset has a `timeKey`, metrics can be grained by time. ```typescript const monthlyRevenue = revenue.by('month'); await analytics.execute(monthlyRevenue, { dimensions: ['country'], }); ``` Supported grains are `day`, `week`, `month`, `quarter`, and `year`. MCP metadata [#mcp-metadata] Named metrics are exposed through MCP `get_dataset_schema`. Use `label` and `description` to make metric intent clear to agents and UIs. ```typescript const averageOrderValue = Orders.metric('averageOrderValue', { uses: { revenue, orderCount }, formula: ({ revenue, orderCount }) => divide(revenue, nullIfZero(orderCount)), label: 'Average Order Value', description: 'Revenue divided by order count.', }); ``` For the normalized metadata shape used by MCP introspection, see [Catalog](/docs/datasets/catalog). --- # Multi-tenancy (/docs/datasets/multi-tenancy) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Dataset multi-tenancy is fail-closed. If a dataset declares a `tenantKey`, metric and dataset queries require trusted tenant context at execution time. `tenantKey` is the physical column on the source table. `runtime.tenant` is the trusted tenant value supplied by your server, job, MCP process, or runtime integration. Like Serve tenant auto-injection for hand-written queries, datasets inject tenant filtering automatically. The difference is where the tenant column comes from: dataset queries use the dataset `tenantKey`, while Serve builder queries use the Serve tenant configuration. Declare the tenant key [#declare-the-tenant-key] ```typescript export const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', dimensions: { tenantId: dimension.string({ column: 'tenant_id' }), status: dimension.string(), country: dimension.string(), }, measures: { revenue: measure.sum('amount'), }, }); const revenue = Orders.metric('revenue', { measure: 'revenue' }); ``` The `tenantKey` uses the ClickHouse column name. The optional `tenantId` dimension gives callers a semantic field name for schema introspection, but callers should not provide tenant filters when runtime tenancy is active. Provide tenant context [#provide-tenant-context] ```typescript await analytics.execute(revenue, {}, { runtime: { tenant: 'tenant_123', }, }); ``` This automatically injects a tenant predicate equivalent to: ```sql WHERE tenant_id = 'tenant_123' ``` If you omit tenant context for a tenant-scoped dataset, the query is rejected: ```typescript await analytics.execute(revenue); // Error: Dataset "orders" requires runtime tenant scoping. ``` Tenant runtime values [#tenant-runtime-values] `runtime.tenant` supports a single tenant, a set of tenants, or an explicit trusted cross-tenant mode. ```typescript // Single tenant. { runtime: { tenant: 'tenant_123' } } // Single tenant, object form. { runtime: { tenant: { id: 'tenant_123' } } } // Trusted multi-tenant scope. { runtime: { tenant: { in: ['tenant_123', 'tenant_456'] } } } // Trusted cross-tenant scope. { runtime: { tenant: { scope: 'all' } } } ``` Use `{ in: [...] }` for admin or reporting surfaces scoped to a known set of tenants. Use `{ scope: 'all' }` only in trusted contexts like internal jobs or admin dashboards. Do not use cross-tenant scope in request-facing clients or MCP servers. Tenant filters are rejected [#tenant-filters-are-rejected] When runtime tenancy is active, explicit filters on the tenant field are rejected. This prevents duplicate or conflicting tenant predicates. ```typescript await analytics.execute(revenue, { filters: [eq('tenantId', 'tenant_123')], }, { runtime: { tenant: 'tenant_123', }, }); // Error: Cannot filter on tenant field "tenantId" when runtime tenancy enforcement is active. ``` Tenant identity should come from trusted runtime state, not end-user query input. Serve tenant context [#serve-tenant-context] With `@hypequery/serve`, configure tenant extraction from auth or request context. Semantic dataset and metric endpoints pass that tenant identity into `@hypequery/datasets`, and datasets auto-inject the filter from `tenantKey`. ```typescript const api = serve({ datasets: { orders: Orders }, queryBuilder: db, tenant: { extract: (auth) => auth.tenantId, required: true, }, }); ``` Serve tenant `column` configuration is useful for hand-written builder queries. Semantic dataset endpoints use the dataset `tenantKey` for tenant filtering. For simple internal or first-party apps, the auth strategy can read a tenant header and expose it as auth context: ```typescript import { initServe } from '@hypequery/serve'; const { serve } = initServe({ auth: async ({ request }) => ({ tenantId: request.headers['x-tenant-id'], }), tenant: { extract: (auth) => auth.tenantId, required: true, }, }); export const api = serve({ queryBuilder: db, datasets: { orders: Orders }, metrics: { revenue }, }); ``` Only trust tenant headers at a boundary you control, such as a server-to-server call, reverse proxy, or authenticated application route. Public clients should derive tenant identity from verified auth instead of accepting arbitrary header values. MCP tenant context [#mcp-tenant-context] For MCP, run tenant-scoped servers with a trusted `tenantId`. The MCP server forwards that value as dataset runtime tenant context for `query_dataset` and `query_metric`. If registered datasets have `tenantKey`, the MCP server requires tenant scope at startup. This keeps agent calls inside the governed tenant boundary. --- # Overview (/docs/datasets/overview) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Datasets are type-safe semantic analytics definitions. A dataset models a source table or view with dimensions, measures, metrics, tenant keys, and time keys in TypeScript. Use datasets when query logic has started to matter beyond one file: * define table semantics once * reuse dimensions and measures alongside normal query builder code * apply tenant and time context consistently * serve semantic dataset endpoints from the same definitions * keep the layer in your codebase instead of YAML or a separate platform `@hypequery/datasets` owns semantic meaning and planning. `@hypequery/clickhouse` owns query construction and execution. `@hypequery/serve` owns HTTP/runtime delivery. Install [#install] ```bash npm install @hypequery/datasets @hypequery/clickhouse # or pnpm add @hypequery/datasets @hypequery/clickhouse ``` Quick start [#quick-start] ```typescript import { createDatasetClient, dataset, dimension, divide, eq, measure, nullIfZero, } from '@hypequery/datasets'; import { createQueryBuilder } from '@hypequery/clickhouse'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const analytics = createDatasetClient({ queryBuilder: db }); const Orders = dataset('orders', { source: 'orders', tenantKey: 'tenant_id', timeKey: 'created_at', dimensions: { id: dimension.number(), tenantId: dimension.string({ column: 'tenant_id' }), status: dimension.string(), country: dimension.string(), createdAt: dimension.timestamp({ column: 'created_at' }), }, measures: { revenue: measure.sum('amount'), orderCount: measure.count('id'), }, }); // Execute a dataset query directly when you want multiple measures. const byCountry = await analytics.execute(Orders, { dimensions: ['country'], measures: ['revenue', 'orderCount'], filters: [eq('status', 'completed')], limit: 10, }); // Promote reusable KPIs into metrics. const revenue = Orders.metric('revenue', { measure: 'revenue' }); const orderCount = Orders.metric('orderCount', { measure: 'orderCount' }); // Derived metrics compose base metrics from the same dataset. const averageOrderValue = Orders.metric('averageOrderValue', { uses: { revenue, orderCount }, formula: ({ revenue, orderCount }) => divide(revenue, nullIfZero(orderCount)), }); const aovByCountry = await analytics.execute(averageOrderValue, { dimensions: ['country'], filters: [eq('status', 'completed')], orderBy: [{ field: 'averageOrderValue', direction: 'desc' }], limit: 10, }); ``` --- # Relationships (/docs/datasets/relationships) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Relationships model how one dataset links to another. To-one relationships (`belongsTo`, `hasOne`) are queryable: dataset and metric queries can select, filter, and order by fields on the related dataset one hop deep, and hypequery executes the join for you. To-many relationships (`hasMany`) are metadata only. Defining relationships [#defining-relationships] Declare relationships in the dataset config. The target is a lazy reference (`() => Dataset`) so datasets can reference each other without import-order problems. `from` is the join column on this dataset's table; `to` is the join column on the target's table. ```typescript import { dataset, dimension, measure, belongsTo, hasMany } from '@hypequery/datasets'; const Customers = dataset('customers', { source: 'customers', dimensions: { id: dimension.string(), country: dimension.string(), tier: dimension.string(), }, }); const LineItems = dataset('line_items', { source: 'line_items', dimensions: { id: dimension.string(), sku: dimension.string(), }, }); const Orders = dataset('orders', { source: 'orders', dimensions: { id: dimension.string(), status: dimension.string(), amount: dimension.number(), }, measures: { revenue: measure.sum('amount'), }, relationships: { customer: belongsTo(() => Customers, { from: 'customer_id', to: 'id' }), items: hasMany(() => LineItems, { from: 'id', to: 'order_id' }), }, }); ``` The three helpers describe where the foreign key lives: * `belongsTo` — many-to-one; the FK is on this table (`orders.customer_id → customers.id`). * `hasOne` — one-to-one; the FK is on the target table. * `hasMany` — one-to-many; the FK is on the target table. Metadata only — see below. Querying related fields [#querying-related-fields] Reference to-one related dimensions as `.` anywhere a dimension name is accepted: `dimensions`, `filters`, and `orderBy`, in both dataset and metric queries. ```typescript const result = await analytics.execute(Orders, { dimensions: ['customer.country'], measures: ['revenue'], filters: [{ field: 'customer.tier', operator: 'eq', value: 'enterprise' }], orderBy: [{ field: 'customer.country', direction: 'asc' }], }); // Rows are typed: result.data[0]['customer.country'] is string | undefined ``` Result rows key joined columns by their qualified name (`'customer.country'`), and the row types include them, so projections stay fully typed end to end — including through Serve endpoints and the React hooks. Join semantics [#join-semantics] Relationship traversal executes as a ClickHouse `LEFT ANY JOIN` (first match), aliased by the relationship name: * Base rows always survive. An order with no matching customer keeps its measures; its joined columns are `NULL`. * At most one target row matches per base row, so duplicate join keys on the target can never fan out and inflate aggregates. The in-memory backend applies the same first-match rule. * Filtering on a joined column excludes base rows without a match (the standard SQL behavior: `NULL` fails the comparison). Queries that reference no relationship fields generate exactly the same SQL as before relationships existed — there is no cost until you traverse. Rules and limits [#rules-and-limits] Validation rejects, with a specific error message: * **More than one hop.** `customer.region.name` is not supported; only `.`. * **`hasMany` traversal.** Joining a to-many relationship would fan out rows and corrupt aggregates, so `hasMany` stays metadata only. * **SQL-backed target dimensions.** Dimensions defined with a raw `sql` expression on the target are not yet queryable through a relationship. * **Measures across relationships.** Measures aggregate the base dataset only. * **Ordering by an unselected joined field.** As with local dimensions, a qualified `orderBy` field must also be selected as a dimension. At definition time, `dataset()` rejects relationship names that collide with the dataset's own `source` table (the join alias would shadow the base table) or contain a dot. Multi-tenancy [#multi-tenancy] When runtime tenant enforcement is active and the target dataset declares a `tenantKey`, the tenant predicate is applied to the joined table **inside the join condition**. Rows from other tenants are never joined — they surface as `NULL`s rather than leaking values — and base-table scoping continues to apply as usual. Explicitly filtering on the target's tenant column is rejected while enforcement is active, same as on the base dataset. Metadata [#metadata] The [catalog](/docs/datasets/catalog) and the versioned semantic contract expose relationship metadata, so tools and agents can discover what is traversable: ```json { "relationships": { "customer": { "kind": "belongsTo", "target": "customers", "from": "customer_id", "to": "id", "queryable": true, "fields": ["customer.id", "customer.country", "customer.tier"] }, "items": { "kind": "hasMany", "target": "line_items", "from": "id", "to": "order_id", "queryable": false, "fields": [] } } } ``` `fields` lists the qualified names a query may reference. The same list flows into [generated tools](/docs/datasets/tool-generation) (enum schemas for agents), Serve's OpenAPI input schemas, and MCP's `get_dataset_schema`, so every surface advertises exactly what the validators accept. --- # Serve integration (/docs/datasets/serve-integration) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; `@hypequery/serve` can expose semantic metric and dataset endpoints from the same definitions you use with `createDatasetClient`. Use Serve when dataset logic needs an HTTP boundary, OpenAPI/docs, auth, tenancy, caching, middleware, or React integration. Register datasets and metrics [#register-datasets-and-metrics] Pass a query builder plus the datasets and metrics you want to expose. ```typescript import { initServe } from '@hypequery/serve'; import { db } from './client.js'; import { Orders, revenue } from './datasets/orders.js'; const { query, serve } = initServe({ context: () => ({ db }), }); export const api = serve({ queryBuilder: db, metrics: { revenue }, datasets: { orders: Orders }, }); ``` Metric endpoints validate dimensions, filters, time grains, and ordering against the metric contract. Dataset endpoints validate dimensions, measures, filters, time grains, and ordering against the dataset definition. A `limit` above the endpoint's `maxLimit` is clamped to `maxLimit` rather than rejected. Endpoint shapes [#endpoint-shapes] Semantic endpoints are generated as POST endpoints. | Config | Endpoint shape | Use it for | | ------------------------------ | ------------------------ | ----------------------------- | | `metrics: { revenue }` | `/metrics/revenue` | One named KPI contract | | `datasets: { orders: Orders }` | `/datasets/orders/query` | Flexible same-dataset rollups | The exact URL is prefixed by your Serve `basePath`. Metric endpoint input [#metric-endpoint-input] ```json { "dimensions": ["country"], "filters": [ { "field": "status", "operator": "eq", "value": "completed" } ], "orderBy": [ { "field": "revenue", "direction": "desc" } ], "limit": 10 } ``` Dataset endpoint input [#dataset-endpoint-input] ```json { "dimensions": ["country", "status"], "measures": ["revenue", "orderCount"], "filters": [ { "field": "status", "operator": "eq", "value": "completed" } ], "limit": 10 } ``` Pagination [#pagination] Both endpoint types support offset pagination. Send `limit` and `offset`; the response reports the served `offset` and a `hasMore` flag so clients know whether another page exists. ```json { "dimensions": ["country"], "measures": ["revenue"], "limit": 50, "offset": 50 } ``` In React, `useInfiniteMetric` and `useInfiniteDataset` handle `offset`/`hasMore` for you. Include result metadata [#include-result-metadata] Semantic endpoints return `{ data }` by default. Include metadata with either `includeMeta: true` in the request body or the `x-include-meta: true` header. ```json { "dimensions": ["country"], "limit": 10, "includeMeta": true } ``` Metadata can include generated SQL, timing, row count, tenant, and pagination. Query definitions plus datasets [#query-definitions-plus-datasets] You can expose hand-authored query definitions and generated semantic endpoints from the same API. ```typescript const activeOrders = query({ query: ({ ctx }) => ctx.db .table('orders') .where('status', 'eq', 'active') .limit(10) .execute(), }); export const api = serve({ queryBuilder: db, queries: { activeOrders }, metrics: { revenue }, datasets: { orders: Orders }, }); ``` Use hand-authored queries for bespoke application endpoints. Use metrics and datasets for governed analytics endpoints generated from semantic definitions. Per-entry options [#per-entry-options] Datasets and metrics can be registered directly or with endpoint-specific options. ```typescript export const api = serve({ queryBuilder: db, metrics: { revenue: { metric: revenue, requiredRoles: ['analytics'], cache: 60_000, maxLimit: 100, }, }, datasets: { orders: { dataset: Orders, requiredRoles: ['analytics'], cache: 60_000, maxLimit: 100, }, }, }); ``` For routing, auth, OpenAPI, and React hooks, see the [Serve docs](/docs/http-openapi) and [React docs](/docs/react/getting-started). --- # Time grains (/docs/datasets/time-grains) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; When a dataset has a `timeKey`, you can use `.by(grain)` on metrics to aggregate by time periods. ```typescript const revenue = Orders.metric('revenue', { measure: 'revenue' }); const monthlyRevenue = revenue.by('month'); await analytics.execute(monthlyRevenue, { dimensions: ['country'], }); ``` Supported grains: * `day` * `week` * `month` * `quarter` * `year` Time key and timestamp dimensions [#time-key-and-timestamp-dimensions] `timeKey` names the physical timestamp column used for time bucketing. It does not create a selectable dimension by itself. If you also want callers to select, filter, or group by that timestamp through the dataset API, add a timestamp dimension that maps a semantic name to the same column: ```typescript const Orders = dataset('orders', { source: 'orders', timeKey: 'created_at', dimensions: { createdAt: dimension.timestamp({ column: 'created_at' }), country: dimension.string(), }, measures: { revenue: measure.sum('amount'), }, }); ``` With that setup, `.by('month')` buckets on `created_at`, while dataset queries can still use the semantic dimension name `createdAt`. --- # Tool Generation (/docs/datasets/tool-generation) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; `@hypequery/datasets` can generate tool metadata from the same catalog used by Serve and MCP. Use this when an agent runtime needs constrained schemas for dataset queries instead of direct access to SQL. Generated tools expose declared datasets, dimensions, measures, filters, order fields, time grains, and limits. They do not expose tenant selection by default, and SQL is removed from tool results unless `includeSql` is enabled. Catalog Tool [#catalog-tool] Catalog mode creates one generic `query_dataset` tool. ```typescript import { createDatasetClient, generateDatasetTools, toOpenAITools, } from '@hypequery/datasets'; import { Orders } from './datasets/orders.js'; import { db } from './db.js'; const analytics = createDatasetClient({ queryBuilder: db }); const tools = generateDatasetTools({ datasets: { orders: Orders }, analytics, mode: 'catalog', }); const openaiTools = toOpenAITools(tools); ``` The generated schema includes enums derived from the dataset catalog. Queryable [relationship fields](/docs/datasets/relationships) (for example `customer.country`) appear in the dimension, filter, and order enums alongside local dimensions. ```json { "name": "query_dataset", "parameters": { "type": "object", "properties": { "dataset": { "type": "string", "enum": ["orders"] }, "dimensions": { "type": "array", "items": { "type": "string", "enum": ["status", "createdAt"] } }, "measures": { "type": "array", "items": { "type": "string", "enum": ["revenue", "orderCount"] } } }, "required": ["dataset"], "additionalProperties": false } } ``` Per-Dataset Tools [#per-dataset-tools] Per-dataset mode creates one tool per dataset, such as `query_orders`. ```typescript const tools = generateDatasetTools({ datasets: { orders: Orders, customers: Customers, }, analytics, mode: 'per-dataset', }); ``` Per-Metric Tools [#per-metric-tools] Per-metric mode creates one tool per published named metric. Use `publishDatasets` so the catalog and executor share the same public names. ```typescript import { publishDatasets } from '@hypequery/datasets'; const totalRevenue = Orders.metric('totalRevenue', { measure: 'revenue', label: 'Total Revenue', }); const datasets = publishDatasets() .publish(Orders, { metrics: { totalRevenue } }) .build(); const tools = generateDatasetTools({ datasets, analytics, mode: 'per-metric', }); ``` Per-metric tools do not include a `measures` input. They accept dimensions, filters, order, limits, offsets, and time grains that are valid for that metric's dataset. Adapters [#adapters] Use adapters when a runtime needs a specific metadata shape. ```typescript import { toAISDKTools, toMcpTools, toOpenAITools, } from '@hypequery/datasets'; const openaiTools = toOpenAITools(tools); const aiSdkTools = toAISDKTools(tools); const mcpTools = toMcpTools(tools); ``` `toAISDKTools()` returns plain tool objects with `description`, `parameters`, and `execute`. If your AI SDK version expects a wrapper such as `tool(...)`, wrap the returned entries in your application. SQL Redaction [#sql-redaction] Generated tools redact `meta.sql` by default. ```typescript const tools = generateDatasetTools({ datasets: { orders: Orders }, analytics, includeSql: false, }); ``` Set `includeSql: true` only for trusted debugging contexts. Validation [#validation] Tool execution performs catalog-level validation before calling `analytics.execute(...)`. Invalid datasets, fields, filter operators, order fields, grains, and limits fail with errors that agents can repair. The dataset client remains the authoritative validator and executor. Tool generation is a constrained metadata and execution wrapper around the same semantic query path. --- # Clients (/docs/mcp/clients) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Hypequery MCP runs over stdio. Your MCP client launches `hypequery-mcp` and passes a config path. Claude Desktop [#claude-desktop] Add a server entry to Claude Desktop's config file: * macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` * Windows: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "hypequery": { "command": "npx", "args": [ "-y", "@hypequery/mcp", "--config", "/absolute/path/to/mcp-config.mjs" ], "env": { "CLICKHOUSE_URL": "https://example.clickhouse.cloud:8443", "CLICKHOUSE_USER": "default", "CLICKHOUSE_PASSWORD": "password", "CLICKHOUSE_DATABASE": "analytics" } } } } ``` Restart Claude Desktop after changing the config. Cursor [#cursor] Use the same command and arguments in Cursor's MCP configuration: ```json { "mcpServers": { "hypequery": { "command": "npx", "args": [ "-y", "@hypequery/mcp", "--config", "/absolute/path/to/mcp-config.mjs" ], "env": { "CLICKHOUSE_URL": "https://example.clickhouse.cloud:8443", "CLICKHOUSE_USER": "default", "CLICKHOUSE_PASSWORD": "password", "CLICKHOUSE_DATABASE": "analytics" } } } } ``` Test the server directly [#test-the-server-directly] Before connecting a client, run the server from a terminal: ```bash npx -y @hypequery/mcp --config /absolute/path/to/mcp-config.mjs ``` You should see startup logs on stderr. The process keeps running because the MCP client normally owns its lifecycle. Use absolute paths in client config. Desktop clients often launch from a different working directory than your terminal. --- # Configuration (/docs/mcp/configuration) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; The CLI loads a config file with dynamic `import()`. The config must export: * `datasets`: a registry of dataset names to dataset instances * `analytics`: a dataset client created with `createDatasetClient` Minimal config [#minimal-config] ```javascript // mcp-config.mjs import { createQueryBuilder } from '@hypequery/clickhouse'; import { createDatasetClient } from '@hypequery/datasets'; import { Users } from './datasets/users.js'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL, username: process.env.CLICKHOUSE_USER, password: process.env.CLICKHOUSE_PASSWORD, database: process.env.CLICKHOUSE_DATABASE, }); export const datasets = { users: Users, }; export const analytics = createDatasetClient({ queryBuilder: db }); ``` Run it: ```bash npx hypequery-mcp --config ./mcp-config.mjs ``` Expose named metrics [#expose-named-metrics] The MCP server can query dataset measures through `query_dataset`. If you want agents to call stable named KPIs through `query_metric`, attach metric handles to the dataset registry. ```javascript import { publishDatasets } from '@hypequery/datasets'; import { Orders } from './datasets/orders.js'; const revenue = Orders.metric('revenue', { measure: 'revenue' }); const orderCount = Orders.metric('orderCount', { measure: 'orderCount' }); export const datasets = publishDatasets() .publish(Orders, { metrics: { revenue, orderCount } }) .build(); ``` Publish multiple datasets [#publish-multiple-datasets] Chain one `publish` call per dataset. Metrics are scoped to the dataset they are published with, and `alias` sets the public registry name when it should differ from the dataset's own name. ```javascript import { publishDatasets } from '@hypequery/datasets'; import { Orders } from './datasets/orders.js'; import { Customers } from './datasets/customers.js'; const revenue = Orders.metric('revenue', { measure: 'revenue' }); const orderCount = Orders.metric('orderCount', { measure: 'orderCount' }); const customerCount = Customers.metric('customerCount', { measure: 'customerCount' }); export const datasets = publishDatasets() .publish(Orders, { metrics: { revenue, orderCount } }) .publish(Customers, { alias: 'accounts', metrics: { customerCount } }) .build(); ``` Agents see two datasets, `orders` and `accounts`, and three metric tools: `query_revenue`, `query_orderCount`, and `query_customerCount`. Publishing a metric under a dataset that does not own it is rejected at publish time, so `customerCount` cannot be attached to `Orders` by mistake. Related datasets [#related-datasets] Relationships follow the alias their target was published under. Because `Customers` is published as `accounts`, the `customer` relationship declared on `Orders` targets `accounts`, and agents can select `customer.tier` against a dataset they can also list on its own. See [Relationships](/docs/datasets/relationships). Publish every dataset that a published dataset relates to. An unpublished target keeps its original name, so the catalog advertises a relationship pointing at a dataset agents cannot list or query. Metadata for agents [#metadata-for-agents] MCP schema introspection exposes dataset, dimension, and metric metadata. Add labels and descriptions where field names alone are ambiguous. ```javascript const revenue = Orders.metric('revenue', { measure: 'revenue', label: 'Revenue', description: 'Total completed order revenue.', }); ``` Config path [#config-path] Use an absolute path when configuring desktop clients. Relative paths depend on the client's launch directory and are a common source of connection failures. MCP stdio uses stdout for protocol messages. The Hypequery MCP CLI redirects `console.log`, `console.info`, and `console.debug` to stderr so config logs do not corrupt the MCP stream. --- # Overview (/docs/mcp/overview) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; `@hypequery/mcp` exposes your Hypequery semantic layer through the Model Context Protocol. Agents can discover datasets, inspect schema metadata, query named metrics, and run governed dataset queries without receiving raw SQL access. Use MCP when you want an agent to work inside a constrained analytics vocabulary: * expose only the datasets you register * expose only named metrics you attach to those datasets * let agents inspect dimensions, labels, descriptions, filters, tenant keys, and time keys * execute through the same `createDatasetClient` path used by the rest of your app * avoid handing an LLM a direct SQL console MCP is a capability boundary, not an authentication layer. If your data is multi-tenant, read Safety model before exposing a server broadly. Install [#install] ```bash npm install @hypequery/mcp @hypequery/datasets @hypequery/clickhouse # or pnpm add @hypequery/mcp @hypequery/datasets @hypequery/clickhouse ``` Quick start [#quick-start] Create an ESM config file that exports `datasets` and `analytics`. ```javascript // mcp-config.mjs import { createQueryBuilder } from '@hypequery/clickhouse'; import { createDatasetClient, publishDatasets } from '@hypequery/datasets'; import { Orders } from './datasets/orders.js'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL, username: process.env.CLICKHOUSE_USER, password: process.env.CLICKHOUSE_PASSWORD, database: process.env.CLICKHOUSE_DATABASE, }); const revenue = Orders.metric('revenue', { measure: 'revenue', label: 'Revenue', description: 'Total order revenue.', }); export const datasets = publishDatasets() .publish(Orders, { metrics: { revenue } }) .build(); export const analytics = createDatasetClient({ queryBuilder: db }); ``` Run the MCP server with the config file path: ```bash npx hypequery-mcp --config ./mcp-config.mjs ``` --- # Programmatic usage (/docs/mcp/programmatic) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Use `createMCPServer` when you want to start the MCP server from your own Node process instead of using the CLI. ```typescript import { createMCPServer } from '@hypequery/mcp'; import { createDatasetClient } from '@hypequery/datasets'; import { createQueryBuilder } from '@hypequery/clickhouse'; import { datasets } from './datasets/index.js'; const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const analytics = createDatasetClient({ queryBuilder: db }); const server = await createMCPServer({ datasets, analytics, name: 'analytics-mcp', version: '1.0.0', }); // The server is now running over stdio. ``` `createMCPServer` returns the server instance after calling `start()`. The stdio transport is intended for MCP clients that launch and manage the process. Tenant-scoped servers [#tenant-scoped-servers] If registered datasets have `tenantKey`, pass a trusted `tenantId` when creating the MCP server. ```typescript const server = await createMCPServer({ datasets, analytics, tenantId: 'tenant_123', }); ``` The server forwards that value as dataset runtime tenant context for `query_dataset` and `query_metric`. Stop the server [#stop-the-server] If your application owns the lifecycle, call `stop()`. ```typescript await server.stop(); ``` Config shape [#config-shape] The programmatic API accepts the same core inputs as the CLI config, plus optional server metadata and tenant scope. ```typescript type MCPServerConfig = { datasets: Record>; analytics: DatasetClient; name?: string; version?: string; tenantId?: string; includeSql?: boolean; }; ``` Set `includeSql: true` only in trusted debugging contexts. Query tools redact generated SQL by default. --- # Safety model (/docs/mcp/safety) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; MCP should expose a governed analytics layer, not a database console. With Hypequery MCP, the agent can only use the datasets and metrics you register. It receives tools for discovery and semantic querying. It does not receive a raw SQL execution tool. What MCP constrains [#what-mcp-constrains] * which datasets are visible * which named metrics are callable * which dimensions and measures can be requested * which filter operators the tool schema accepts * query execution through the dataset client * tenant scoping when the server is configured with a trusted `tenantId` MCP can also expose relationship metadata through schema introspection, but current dataset execution is still same-dataset. What MCP does not replace [#what-mcp-does-not-replace] MCP does not replace authentication, authorization, or tenant identity. The stock stdio server receives tool calls from a local MCP client and executes them through the `analytics` dataset client you provide. Do not expose a shared multi-tenant MCP server unless the process is already scoped to one tenant. Treat MCP clients as powerful local operators, not public end-user clients. Safer patterns [#safer-patterns] Prefer one of these patterns: * run MCP for local development or internal admin use only * expose only non-sensitive aggregate datasets * attach only the named metrics agents should call * run a tenant-specific MCP process with `tenantId` * keep sensitive tables out of the exported `datasets` registry * enable `includeSql` only in trusted debugging sessions Registry boundary [#registry-boundary] The exported `datasets` registry is the main boundary. ```javascript import { publishDatasets } from '@hypequery/datasets'; export const datasets = publishDatasets() .publish(Orders, { metrics: { revenue } }) .build(); ``` If a dataset is not in this object, the MCP server cannot list it, introspect it, or query it. If a metric is not attached to the dataset registry, the agent cannot call it through `query_metric`. Tenant boundary [#tenant-boundary] If registered datasets have `tenantKey`, the MCP server requires a trusted tenant scope. In the programmatic API, pass `tenantId`. ```typescript await createMCPServer({ datasets, analytics, tenantId: 'tenant_123', }); ``` The server forwards that value into dataset execution as runtime tenant context. Agents should not choose tenant ids through filters. The `hypequery-mcp` CLI does not accept a `tenantId`, so it cannot serve tenant-keyed datasets — startup fails with `MCP server tenantId is required for tenant-scoped datasets`. Serve multi-tenant datasets through the programmatic `createMCPServer({ ..., tenantId })` instead, one process per tenant. Raw SQL boundary [#raw-sql-boundary] The MCP server exposes semantic tools: * `list_datasets` * `get_dataset_schema` * `query_dataset` * `query_metric` It does not expose a generic `run_sql` tool. Generated SQL and SQL-backed field expressions are also redacted from MCP responses by default. Programmatic servers can opt in with `includeSql: true` for trusted local debugging. For the broader application boundary between semantic runtime input and trusted raw SQL APIs, see [Trust Boundaries](/docs/reference/trust-boundaries). --- # Tools (/docs/mcp/tools) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; The MCP server exposes semantic tools over your dataset registry. Agents do not receive a SQL tool. list_datasets [#list_datasets] Lists registered datasets with description, dimension count, measure count, and metric count. ```json { "name": "list_datasets" } ``` Example response: ```json { "datasets": [ { "name": "orders", "description": "Customer orders and revenue data", "dimensionCount": 5, "measureCount": 4, "metricCount": 2 } ], "total": 1 } ``` get_dataset_schema [#get_dataset_schema] Returns metadata for one dataset, including dimensions, measures, named metrics, filters, tenant key, time key, limits, and relationship metadata when present. Relationship entries include `queryable` and `fields`, so agents can discover which related fields (for example `customer.country`) a query may reference. See [Relationships](/docs/datasets/relationships). ```json { "name": "get_dataset_schema", "arguments": { "dataset": "orders" } } ``` Labels and descriptions from your dataset definitions are included here, so they matter for agent behavior. query_metric [#query_metric] Runs one named metric handle attached to a dataset. ```json { "name": "query_metric", "arguments": { "dataset": "orders", "metric": "revenue", "dimensions": ["country"], "filters": [ { "field": "status", "operator": "eq", "value": "completed" } ], "orderBy": [ { "field": "revenue", "direction": "desc" } ], "limit": 10 } } ``` Use `query_metric` when the agent should ask for a stable KPI such as `revenue`, `averageOrderValue`, or `monthlyRevenue`. query_dataset [#query_dataset] Runs an ad hoc dataset query with selected dimensions and measures. ```json { "name": "query_dataset", "arguments": { "dataset": "orders", "dimensions": ["country", "status"], "measures": ["revenue", "orderCount"], "filters": [ { "field": "status", "operator": "eq", "value": "completed" } ], "grain": "month", "limit": 100 } } ``` The MCP argument is called `measures`, matching dataset queries in `@hypequery/datasets`. Derived or named metrics such as `averageOrderValue` are not measures and can only be queried through `query_metric`. At least one dimension or measure must be specified, otherwise the query is rejected. Pagination [#pagination] `query_metric` and `query_dataset` both accept `limit` and `offset` for paging through large result sets. `offset` skips the given number of rows before returning results. Prompt [#prompt] The server also exposes a `dataset_guide` prompt that helps an MCP client explain how to query the registered datasets. Operators and grains [#operators-and-grains] Supported filter operators: * `eq` * `neq` * `gt` * `gte` * `lt` * `lte` * `in` * `notIn` * `between` * `like` Supported time grains: * `day` * `week` * `month` * `quarter` * `year` --- # Advanced Filtering (/docs/query-building/advanced-filtering) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Advanced Filtering [#advanced-filtering] hypequery exposes powerful filtering controls. Use fluent `where` clauses for simple logic or the `CrossFilter` helper when you need nested groups, reusable predicates, or top-N shortcuts. These examples use a typed standalone `db` client so the query builder stays the focus. Basic Filtering [#basic-filtering] Chain multiple `where` clauses inside any query. Every predicate stays type-safe thanks to the schema you generated: ```typescript const activeAdultUsers = await db .table('users') .where('age', 'gt', 18) .where('status', 'eq', 'active') .execute(); ``` Complex Filtering with CrossFilter [#complex-filtering-with-crossfilter] `CrossFilter` lets you compose nested AND/OR groups, reuse common predicates, and share filter trees across queries. Basic CrossFilter usage [#basic-crossfilter-usage] ```typescript import { CrossFilter } from '@hypequery/clickhouse'; const ordersFilter = new CrossFilter() .add({ column: 'status', operator: 'in', value: ['active', 'pending'], }) .addGroup( [ { column: 'created_at', operator: 'gte', value: new Date('2023-01-01'), }, { column: 'total', operator: 'gt', value: 1000, }, ], 'OR', ); const filteredOrders = await db.table('orders').applyCrossFilters(ordersFilter).execute(); ``` FilterConditionInput interface [#filterconditioninput-interface] ```typescript interface FilterConditionInput { column: keyof OriginalT | TableColumn; operator: FilterOperator; value: T; conjunction?: 'AND' | 'OR'; } ``` * `column`: Column reference (local table or joined tables). * `operator`: Comparison operator (see table below). * `value`: The literal/array passed to that operator. * `conjunction`: Optional logical operator to connect the condition to its siblings. Supported operators [#supported-operators] | Operator | Description | Value Type | Example | | --------- | --------------------- | ----------------- | -------------------------------------------- | | `eq` | Equal to | Any | `{ operator: 'eq', value: 'active' }` | | `neq` | Not equal to | Any | `{ operator: 'neq', value: 'inactive' }` | | `gt` | Greater than | Number, Date | `{ operator: 'gt', value: 100 }` | | `gte` | Greater than or equal | Number, Date | `{ operator: 'gte', value: 100 }` | | `lt` | Less than | Number, Date | `{ operator: 'lt', value: 1000 }` | | `lte` | Less than or equal | Number, Date | `{ operator: 'lte', value: 1000 }` | | `in` | In array | Array | `{ operator: 'in', value: ['A', 'B'] }` | | `notIn` | Not in array | Array | `{ operator: 'notIn', value: ['X'] }` | | `between` | Between range | Array of 2 values | `{ operator: 'between', value: [100, 200] }` | | `like` | Pattern match | String | `{ operator: 'like', value: '%test%' }` | | `notLike` | Not pattern match | String | `{ operator: 'notLike', value: '%admin%' }` | FilterGroup interface [#filtergroup-interface] ```typescript interface FilterGroup { operator: 'AND' | 'OR'; conditions: Array; limit?: number; orderBy?: { column: keyof OriginalT; direction: 'ASC' | 'DESC'; }; } ``` * `operator`: Controls how nested conditions combine. * `conditions`: Array of conditions or deeper groups. * `limit`: Optional row cap for the subquery powering the filter. * `orderBy`: Sort criteria for that subquery. CrossFilter methods [#crossfilter-methods] Core helpers [#core-helpers] * `add(condition: FilterConditionInput)` – append a single predicate. * `addMultiple(conditions: FilterConditionInput[])` – append multiple conditions. * `addGroup(conditions, operator)` – create nested AND/OR blocks. * `getConditions()` – inspect the current filter tree (useful for tests or logging). ```typescript const filter = new CrossFilter() .add({ column: 'status', operator: 'eq', value: 'active' }) .addMultiple([ { column: 'age', operator: 'gte', value: 18 }, { column: 'age', operator: 'lte', value: 65 }, ]) .addGroup([ { column: 'price', operator: 'gte', value: 100 }, { column: 'price', operator: 'lte', value: 500 }, ], 'AND'); const conditions = filter.getConditions(); ``` Advanced helpers [#advanced-helpers] * `topN(valueColumn, n, orderBy)` – builds a filter targeting the top N by a metric. ```typescript const filter = new CrossFilter().topN('revenue', 10, 'desc'); ``` Using CrossFilter with validated input [#using-crossfilter-with-validated-input] ```typescript type OrdersFilterInput = { dateRange?: [string, string]; customerIds: string[]; }; async function getFilteredOrders(input: OrdersFilterInput) { const filter = new CrossFilter(); if (input.dateRange) { filter.add({ column: 'created_at', operator: 'between', value: input.dateRange, }); } if (input.customerIds.length) { filter.add({ column: 'customer_id', operator: 'in', value: input.customerIds, }); } return db .table('orders') .applyCrossFilters(filter) .execute(); } ``` Date filtering patterns [#date-filtering-patterns] Using date-fns [#using-date-fns] ```typescript import { CrossFilter } from '@hypequery/clickhouse'; import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays } from 'date-fns'; // Today's orders const today = new Date(); const todayFilter = new CrossFilter().add({ column: 'created_at', operator: 'between', value: [startOfDay(today).toISOString(), endOfDay(today).toISOString()], }); const todaysOrders = await db.table('orders').applyCrossFilters(todayFilter).execute(); // Last 7 days const last7DaysFilter = new CrossFilter().add({ column: 'created_at', operator: 'between', value: [subDays(today, 7).toISOString(), today.toISOString()], }); // This month const thisMonthFilter = new CrossFilter().add({ column: 'created_at', operator: 'between', value: [startOfMonth(today).toISOString(), endOfMonth(today).toISOString()], }); ``` Using dayjs [#using-dayjs] ```typescript import dayjs from 'dayjs'; import { CrossFilter } from '@hypequery/clickhouse'; const today = dayjs(); // Today const dayJsTodayFilter = new CrossFilter().add({ column: 'created_at', operator: 'between', value: [today.startOf('day').toISOString(), today.endOf('day').toISOString()], }); const todayEvents = await db.table('events').applyCrossFilters(dayJsTodayFilter).execute(); // Last 30 days const dayJsLast30Filter = new CrossFilter().add({ column: 'created_at', operator: 'between', value: [today.subtract(30, 'day').toISOString(), today.toISOString()], }); ``` --- # Aggregation (/docs/query-building/aggregation) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Aggregation [#aggregation] The query builder provides typed methods for ClickHouse’s common and analytical aggregates. Chain them after `table()`, select the grouping dimensions you want back, and call `groupBy()` before execution. Aggregate methods [#aggregate-methods] | Method | Calculation | | --------------------------------- | ----------------------------------------- | | `sum(column, alias?)` | Sum | | `count(column, alias?)` | Count | | `countDistinct(column, alias?)` | Distinct count | | `avg(column, alias?)` | Average | | `min(column, alias?)` | Minimum | | `max(column, alias?)` | Maximum | | `quantile(column, level, alias?)` | Approximate percentile | | `argMax(column, by, alias?)` | Value from the row where `by` is greatest | | `argMin(column, by, alias?)` | Value from the row where `by` is smallest | | `stddev(column, alias?)` | Sample standard deviation | | `variance(column, alias?)` | Sample variance | The alias is optional. Providing one keeps result names readable and makes them available to `orderBy()`. Revenue by region [#revenue-by-region] ```ts const revenueByRegion = await db .table('orders') .select(['region']) .sum('amount', 'revenue') .count('id', 'order_count') .avg('amount', 'average_order_value') .groupBy('region') .orderBy('revenue', 'DESC') .execute(); ``` Multiple dimensions can be selected and grouped together: ```ts const revenueByRegionAndStatus = await db .table('orders') .select(['region', 'status']) .sum('amount', 'revenue') .groupBy(['region', 'status']) .execute(); ``` Repeated `groupBy()` calls are additive, so this is equivalent: ```ts .groupBy('region') .groupBy('status') ``` Distinct counts [#distinct-counts] ```ts const activeUsers = await db .table('events') .select(['event_type']) .countDistinct('user_id', 'unique_users') .groupBy('event_type') .execute(); ``` The public method is `countDistinct()`. It replaces the older `distinctCount()` spelling found in stale examples. Percentiles and distribution statistics [#percentiles-and-distribution-statistics] ```ts const latency = await db .table('requests') .select(['service']) .quantile('latency_ms', 0.5, 'median_latency') .quantile('latency_ms', 0.95, 'p95_latency') .quantile('latency_ms', 0.99, 'p99_latency') .stddev('latency_ms', 'latency_stddev') .variance('latency_ms', 'latency_variance') .groupBy('service') .execute(); ``` `quantile()` uses ClickHouse’s approximate `quantile(level)` aggregate. `level` must be between `0` and `1`. The semantic dataset layer calls the same concept `measure.percentile()` and provides `measure.median()` as percentile `0.5`. argMax and argMin [#argmax-and-argmin] Use the arg aggregates when you need one field from the row with the latest, earliest, greatest, or smallest value of another field: ```ts const accountState = await db .table('account_events') .select(['account_id']) .argMax('status', 'created_at', 'latest_status') .argMin('status', 'created_at', 'first_status') .groupBy('account_id') .execute(); ``` This is a direct typed expression of `argMax(status, created_at)` and `argMin(status, created_at)` in ClickHouse. Time intervals [#time-intervals] ```ts const dailyRevenue = await db .table('orders') .groupByTimeInterval('created_at', '1 day') .sum('amount', 'revenue') .orderBy('created_at', 'ASC') .execute(); ``` `groupByTimeInterval()` generates a ClickHouse time bucket and adds the corresponding grouping expression. Use the exported time-expression helpers when you need a specific alias or want to compose the expression yourself. Filter before grouping [#filter-before-grouping] ```ts const completedRevenue = await db .table('orders') .where('status', 'eq', 'completed') .where('created_at', 'gte', '2026-01-01') .select(['region']) .sum('amount', 'revenue') .groupBy('region') .execute(); ``` `where()` filters source rows before aggregation. `having()` filters the grouped result: ```ts const largeRegions = await db .table('orders') .select(['region']) .sum('amount', 'revenue') .groupBy('region') .having('revenue > ?', [10000]) .execute(); ``` Use `where()` for source-column filters whenever possible. Use `having()` when the condition depends on an aggregate result. Totals [#totals] `withTotals()` maps to ClickHouse `GROUP BY ... WITH TOTALS`: ```ts const result = await db .table('orders') .select(['region']) .sum('amount', 'revenue') .groupBy('region') .withTotals() .execute(); ``` Expressions and window aggregates [#expressions-and-window-aggregates] For a window function, add a trusted SQL expression to the typed selection: ```ts import { selectExpr } from '@hypequery/clickhouse'; const ranked = await db .table('orders') .select([ 'region', 'amount', selectExpr( 'row_number() OVER (PARTITION BY region ORDER BY amount DESC)', 'rank', ), ]) .execute(); ``` See [What hypequery supports today](/docs/capabilities) for the complete builder and semantic aggregate matrix. --- # Query Basics (/docs/query-building/basics) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Core Concepts [#core-concepts] The query builder is designed to be: * **Type-safe** - TypeScript ensures columns and types are correct * **Fluent** - Chain methods naturally to build complex queries * **ClickHouse-first** - Models ClickHouse query building directly instead of pretending every database behaves the same way Start with a typed ClickHouse client [#start-with-a-typed-clickhouse-client] Start by generating types from your ClickHouse schema if you have not done so already. See the [CLI reference](/docs/reference/api/cli) for `hypequery generate`. The core builder API starts with a typed `db` client. These docs show the raw builder chain first because it is the clearest way to learn: ```typescript import { createClient } from '@clickhouse/client'; import { createQueryBuilder } from '@hypequery/clickhouse'; import type { Schema } from './generated-schema'; const client = createClient({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USER!, password: process.env.CLICKHOUSE_PASSWORD!, database: process.env.CLICKHOUSE_DATABASE!, }); const db = createQueryBuilder({ client }); const activeUsers = await db .table('users') .where('status', 'eq', 'active') .select(['id', 'name', 'email']) .execute(); ``` When you move into query definitions, the builder chain stays the same. The Query Building section keeps examples in terms of a standalone typed `db` client so the builder API is easier to learn in isolation. Query Builder Pattern [#query-builder-pattern] All queries follow this pattern: ```typescript return db .table('table_name') .where('column', 'operator', 'value') .select(['col1', 'col2']) .orderBy('created_at', 'DESC') .limit(10) .execute(); ``` Always finish your query chains with `.execute()` to run the query. Builder immutability [#builder-immutability] Builder methods return a new builder state. In normal chained code that is easy to miss because the fluent style reads naturally, but it matters when you build queries conditionally. ```typescript // Good: reassign as you add optional clauses let query = db.table('users'); if (onlyActive) { query = query.where('status', 'eq', 'active'); } if (limit) { query = query.limit(limit); } const rows = await query.select(['id', 'email']).execute(); ``` ```typescript // Fragile: these calls do not update query unless you keep the return value const query = db.table('users'); if (onlyActive) { query.where('status', 'eq', 'active'); } if (limit) { query.limit(limit); } ``` If you have older code that conditionally calls builder methods without reassigning the returned builder, update it for `v2.0.0`. Query Building Blocks [#query-building-blocks] The query builder is organized into logical concepts. Each concept has detailed documentation: Core Operations [#core-operations] | Concept | Description | Link | | --------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | | **Select** | Choose which columns to return, use aliases, and expressions | [Select →](/docs/query-building/select) | | **Where** | Filter rows using conditions, operators, and predicates | [Where →](/docs/query-building/where) | | **Joins** | Combine data from multiple tables | [Joins →](/docs/query-building/joins) | | **Aggregation** | Group data and calculate summaries (sum, count, avg) | [Aggregation →](/docs/query-building/aggregation) | | **Ordering** | Sort results and paginate with limit/offset | [Ordering →](/docs/query-building/ordering) | | **Subqueries & CTEs** | Compose subqueries, reusable CTEs, and more complex builder flows | [Subqueries & CTEs →](/docs/query-building/subqueries-ctes) | | **SQL Expressions** | Drop to raw SQL fragments and expression helpers when the fluent API is not enough | [SQL Expressions →](/docs/query-building/sql-expressions) | | **Time Functions** | Work with dates, timestamps, and time intervals | [Time Functions →](/docs/query-building/time-functions) | Type Safety [#type-safety] hypequery ensures type safety throughout the query building process: ```typescript // TypeScript knows the exact columns in your schema db .table('users') .select(['id', 'name', 'email']) .execute(); // Returns: Promise> // Invalid columns are caught at compile time db .table('users') .select(['id', 'invalid_column']) // ❌ TypeScript error .execute(); // Operators match column types db .table('users') .where('created_at', 'eq', '2024-01-01') // ✅ Valid .where('age', 'gte', 18) // ✅ Valid .execute(); ``` Execution Methods [#execution-methods] execute() [#execute] Run the query and get all results: ```typescript const users = await db .table('users') .where('status', 'eq', 'active') .select(['id', 'name']) .execute(); ``` stream() [#stream] Stream results for large datasets: ```typescript const stream = await db .table('events') .select(['id', 'data']) .stream(); const reader = stream.getReader(); while (true) { const { done, value: rows } = await reader.read(); if (done) break; // Process rows in batches } ``` streamForEach() [#streamforeach] Process rows with a callback: ```typescript await db .table('events') .select(['id', 'data']) .streamForEach(async (row) => { await processEvent(row); }); ``` --- # Query Caching (/docs/query-building/caching) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Query Caching [#query-caching] Query caching belongs to the typed ClickHouse builder in `@hypequery/clickhouse`. It caches the result of `execute()` so repeated reads do not always hit ClickHouse. Configure a cache provider [#configure-a-cache-provider] Start by enabling caching on the `db` client:
    ```typescript
    import { createQueryBuilder, MemoryCacheProvider } from '@hypequery/clickhouse';

    const db = createQueryBuilder({
      url: process.env.CLICKHOUSE_URL!,
      cache: {
        mode: 'stale-while-revalidate',
        ttlMs: 2_000,
        staleTtlMs: 30_000,
        staleIfError: true,
        provider: new MemoryCacheProvider({ maxEntries: 1_000 }),
      },
    });
    ```
  
Cache an individual query [#cache-an-individual-query] Use `.cache(...)` on the builder chain:
    ```typescript
    const rows = await db
      .table('orders')
      .sum('total', 'revenue')
      .groupBy(['customer_id'])
      .orderBy('revenue', 'DESC')
      .limit(10)
      .cache({ tags: ['orders'], ttlMs: 5_000 })
      .execute();
    ```
  
Use `.cache(...)` when the query should normally be cached everywhere it is used. For one-off overrides, pass cache options directly to `execute(...)`:
    ```typescript
    const rows = await db
      .table('orders')
      .sum('total', 'revenue')
      .groupBy(['customer_id'])
      .execute({
        cache: {
          mode: 'network-first',
          ttlMs: 1_000,
          tags: ['orders', 'dashboards'],
        },
      });
    ```
  
Cache modes [#cache-modes] | Mode | Description | | ------------------------ | ------------------------------------------------------------------------------ | | `cache-first` | Serve hot entries, otherwise fetch and store. | | `network-first` | Always hit ClickHouse; fall back to stale data when `staleIfError` is enabled. | | `stale-while-revalidate` | Serve stale-but-acceptable results immediately and refresh in the background. | | `no-store` | Skip caching entirely. | Recommended defaults: * Use `cache-first` for dashboards and read-heavy widgets where slightly stale data is fine. * Use `network-first` when freshness matters more than latency, but you still want stale fallback during ClickHouse failures. * Use `stale-while-revalidate` when you want fast responses for repeated reads without blocking on a refresh. * Use `no-store` to bypass caching for highly volatile or user-specific reads. Cache options [#cache-options] | Option | What it does | | --------------------------- | ---------------------------------------------------------------------------------------- | | `ttlMs` | How long an entry is considered fresh. | | `staleTtlMs` | Extra time an entry may still be served as stale. | | `cacheTimeMs` | Total time the provider should retain the entry. Defaults to `ttlMs + staleTtlMs`. | | `staleIfError` | In `network-first`, serve stale data if ClickHouse fails and a stale entry is available. | | `dedupe` | Reuse in-flight fetches for the same cache key instead of sending duplicate queries. | | `tags` | Attach tags for later invalidation. | | `key` | Override the generated cache key when you want multiple query shapes to share one entry. | | `namespace` | Isolate entries from other cache consumers sharing the same provider. | | `serialize` / `deserialize` | Customize how cached results are encoded and decoded before storage. | By default, hypequery generates deterministic cache keys from the SQL, parameters, and settings for the query. Invalidate and inspect cache state [#invalidate-and-inspect-cache-state]
    ```typescript
    await db.cache.invalidateKey('hq:v1:analytics:orders:abc123');
    await db.cache.invalidateTags(['orders', 'dashboards']);
    await db.cache.clear();

    await db.cache.warm([
      () => db.table('orders').select(['id']).cache({ tags: ['orders'] }).execute(),
      () => db.table('users').select(['id']).cache({ tags: ['users'] }).execute(),
    ]);

    const stats = db.cache.getStats();
    console.log(stats.hitRate, stats.hits, stats.misses, stats.staleHits, stats.revalidations);
    ```
  
`invalidateTags(...)` requires the active cache provider to implement `deleteByTag(...)`. If it does not, hypequery will warn and only clear its in-memory parsed values for matching tags. Bring your own provider [#bring-your-own-provider] Use a custom `CacheProvider` when you want Redis, Upstash, KV, or another shared store:
    ```typescript
    import type { CacheEntry, CacheProvider } from '@hypequery/clickhouse';
    import { Redis } from 'ioredis';

    class RedisCacheProvider implements CacheProvider {
      constructor(private readonly client = new Redis(process.env.REDIS_URL!)) {}

      async get(key: string) {
        const raw = await this.client.get(key);
        return raw ? (JSON.parse(raw) as CacheEntry) : null;
      }

      async set(key: string, entry: CacheEntry) {
        await this.client.set(key, JSON.stringify(entry), 'PX', entry.cacheTimeMs ?? entry.ttlMs);
      }

      async delete(key: string) {
        await this.client.del(key);
      }

      async deleteByTag(namespace: string, tag: string) {
        const tagKey = `hq:tag:${namespace}:${tag}`;
        const keys = await this.client.smembers(tagKey);
        if (keys.length) await this.client.del(...keys);
        await this.client.del(tagKey);
      }
    }
    ```
  
See Also [#see-also] * [Query Builder API](/docs/reference/api/query-builder) * [Observability](/docs/observability) --- # Helper Methods (/docs/query-building/helper-methods) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; hypequery provides several helper methods and utilities to assist with query building and debugging. This guide covers these additional features. SQL Generation [#sql-generation] toSQL [#tosql] Get the raw SQL string for a query: ```typescript const query = db .table('users') .select(['id', 'name']) .where('active', 'eq', true); const sql = query.toSQL(); console.log(sql); // SELECT id, name FROM users WHERE active = true ``` toSQLWithParams [#tosqlwithparams] Get the SQL string and parameters separately: ```typescript const query = db .table('orders') .where('amount', 'gt', 1000); const { sql, parameters } = query.toSQLWithParams(); console.log(sql); // SELECT * FROM orders WHERE amount > ? console.log(parameters); // [1000] ``` Raw SQL [#raw-sql] Raw Expressions [#raw-expressions] `QueryBuilder.raw()` currently appends raw SQL to the `HAVING` clause. Use it for grouped-query filters that are easier to express directly in SQL: ```typescript const results = await db .table('orders') .groupBy('account_id') .count('id', 'order_count') .raw('COUNT(*) > 10') .execute(); ``` Complex Raw SQL [#complex-raw-sql] Use `having()` or `raw()` for advanced aggregate filters: ```typescript const results = await db .table('events') .select(['event_type']) .count('id', 'event_count') .groupBy(['event_type']) .raw('COUNT(*) > 100 AND uniq(user_id) > 10') .execute(); ``` Query Settings [#query-settings] Basic Settings [#basic-settings] Configure ClickHouse query settings: ```typescript const results = await db .table('large_table') .settings({ max_execution_time: 30, max_memory_usage: '10000000000' }) .execute(); ``` Common Settings [#common-settings] Frequently used settings: ```typescript // Timeout settings .settings({ max_execution_time: 60 }) // Memory settings .settings({ max_memory_usage: '4000000000' }) // Thread settings .settings({ max_threads: 4 }) // Multiple settings .settings({ max_execution_time: 30, max_threads: 2, max_memory_usage: '2000000000' }) ``` Debugging [#debugging] debug [#debug] Print query information for debugging: ```typescript const query = db .table('users') .select(['id', 'name']) .where('active', 'eq', true) .debug(); // Logs: // - Internal builder state // - Internal query config ``` Query Inspection [#query-inspection] getQueryNode [#getquerynode] Access a snapshot of the current structured query node: ```typescript const query = db .table('users') .select(['id', 'name']) .where('active', 'eq', true); const queryNode = query.getQueryNode(); console.log(queryNode); // { // kind: 'select-query', // select: [{ kind: 'selection', selection: 'id' }, ...], // where: { kind: 'condition', ... }, // ... // } ``` getConfig [#getconfig] `getConfig()` is deprecated. If you need to inspect the current query shape, prefer `getQueryNode()`. `getConfig()` now returns a snapshot of the structured query state. It should be treated as legacy inspection API rather than the normal public extension point. `getQueryNode()` and `getConfig()` are for inspection. Do not mutate the returned objects and expect the builder instance to change. Builder methods return new builder state instead. Type Safety [#type-safety] Helper methods maintain type safety: ```typescript interface Schema { users: { id: 'Int32'; name: 'String'; active: 'UInt8'; } } const db = createQueryBuilder(); // TypeScript will catch these errors: db.table('users') .groupBy('status') .raw('COUNT(*) > invalid_column') // No type checking for raw SQL .settings({ invalid_setting: true }); // Error: invalid setting ``` Best Practices [#best-practices] 1. Use Raw SQL Sparingly [#1-use-raw-sql-sparingly] ```typescript // Prefer builder methods when possible db.table('users').where('age', 'gt', 18) // Good // Use raw HAVING fragments only when necessary db.table('users').groupBy('status').raw('COUNT(*) > 10') // Less ideal ``` 2. Debug in Development [#2-debug-in-development] ```typescript if (process.env.NODE_ENV === 'development') { query.debug(); } ``` 3. Handle Settings Carefully [#3-handle-settings-carefully] ```typescript // Consider environment when setting limits const maxMemory = process.env.NODE_ENV === 'production' ? '10000000000' // 10GB in production : '1000000000'; // 1GB in development query.settings({ max_memory_usage: maxMemory }); ``` --- # Inserts (/docs/query-building/inserts) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Inserts [#inserts] The `insert()` method starts a type-safe insert. Row shapes are derived from your schema: `Nullable(...)` columns are optional, every other column is required, and value types are checked at compile time. Basic Usage [#basic-usage] ```typescript const result = await db.insert('events') .values({ id: 1, name: 'signup', created_at: new Date(), // DateTime columns accept string | Date optional_note: null, // Nullable(...) columns are optional }) .execute(); // result: { queryId: string; executed: boolean; summary?: unknown } ``` Pass an array to insert multiple rows in one request, or chain `values()` calls — rows accumulate. An explicit empty array is a valid no-op: no request is sent and `execute()` resolves `{ executed: false }`, so batch jobs don't need to special-case empty batches. ```typescript await db.insert('events') .values([ { id: 1, name: 'signup', created_at: new Date() }, { id: 2, name: 'login', created_at: new Date() }, ]) .execute(); ``` Type Safety [#type-safety] TypeScript rejects invalid inserts before they run: ```typescript db.insert('bad_table'); // ✗ unknown table db.insert('events').values({ id: 'x' }); // ✗ wrong value type db.insert('events').values({ id: 1 }); // ✗ missing required columns db.insert('events').values({ nope: 1, ... }); // ✗ unknown column ``` Value types are widened where ClickHouse accepts more than one input format: * **DateTime / DateTime64 columns** accept `string | Date | number` (`Date` values are converted to ISO-8601 strings; numbers are epoch seconds). * **Date / Date32 columns** accept `'YYYY-MM-DD'` strings only — ClickHouse's JSONEachRow parser rejects datetime strings for Date columns, so the types don't permit `Date` objects there. This also avoids the classic timezone bug where a `Date` created at local midnight lands on a different UTC calendar day. * **Int64 and larger integers** accept `string | number | bigint` (`bigint` values are converted to decimal strings). * **Decimals** accept `number | string`. * **Enums** accept the name or the numeric value. `NaN` and `Infinity` are rejected with an error before the request is sent — JSON serialization would otherwise silently coerce them to `null`. Inserting a Subset of Columns [#inserting-a-subset-of-columns] Use `columns()` to insert only some columns and let ClickHouse fill table `DEFAULT`s for the rest. Call it before `values()` — the accepted row shape narrows to the selected columns: ```typescript await db.insert('events') .columns(['id', 'name']) .values([ { id: 1, name: 'signup' }, { id: 2, name: 'login' }, ]) .execute(); ``` The generated schema doesn't carry `DEFAULT` metadata, so defaulted columns are still required in the full-width row shape. Use columns() to omit them. Insert Settings [#insert-settings] Apply per-insert ClickHouse settings with `settings()` — useful for async inserts: ```typescript await db.insert('events') .values(rows) .settings({ async_insert: 1, wait_for_async_insert: 1 }) .execute(); ``` You can also pass a `queryId` for tracing: `.execute({ queryId: 'my-insert' })`. How It Works [#how-it-works] Inserts run through the ClickHouse client's native insert path using the `JSONEachRow` format — values are never interpolated into SQL text. `date_time_input_format: 'best_effort'` is set by default so ISO-8601 timestamps parse into `DateTime` columns (your settings take precedence). All rows in a `values()` call are buffered and sent as a single request. For very large volumes, insert in batches (streaming inserts are planned). Inserts require the adapter to implement the optional DatabaseAdapter.insert method. The built-in ClickHouse adapter supports it; custom adapters that don't will throw a clear error. Inserts do not invalidate the query result cache. Cached reads refresh when their TTL expires. --- # Join Relationships (/docs/query-building/join-relationships) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; Join Relationships [#join-relationships] Inline joins are often enough for one-off queries. When the same join path appears across multiple queries, `JoinRelationships` gives you a reusable, named relationship that you can apply with `withRelation()`. There are two `withRelation()` modes: * pass a relationship name from `JoinRelationships` for reusable runtime lookup * pass a `JoinPath` object (or chain) directly when you want compile-time table/alias widening `withRelation()` is stricter now. Alias override is only supported for single-step relationships, and direct `JoinPath` usage is the typed path when you want compile-time widening. When to use this [#when-to-use-this] Use join relationships when you want to: * define common join paths once * keep multi-query join logic consistent * reuse multi-step joins without rewriting them * override join type per query while keeping a shared base definition These examples use a typed standalone `db` client so the query builder stays the focus. Define relationships [#define-relationships] Create a `JoinRelationships` registry during app startup:
    ```typescript
    import { createQueryBuilder, JoinRelationships } from '@hypequery/clickhouse';
    import type { Schema } from './generated-schema';

    const relationships = new JoinRelationships();

    relationships.define('orderCustomer', {
      from: 'orders',
      to: 'users',
      leftColumn: 'user_id',
      rightColumn: 'id',
      type: 'LEFT',
    });

    createQueryBuilder.setJoinRelationships(relationships);

    export const db = createQueryBuilder({
      url: process.env.CLICKHOUSE_URL!,
    });
    ```
  
Each relationship includes: * `from`: source table * `to`: joined table * `leftColumn`: column on the source side * `rightColumn`: column on the joined table * `type`: optional default join type * `alias`: optional default alias Use a relationship in a query [#use-a-relationship-in-a-query] Apply the relationship with `withRelation()`:
    ```typescript
    const rows = await db
      .table('orders')
      .withRelation('orderCustomer')
      .select([
        'orders.id',
        'orders.total',
        'users.name',
        'users.email',
      ])
      .execute();
    ```
  
This is equivalent to writing the join inline, but the join path now lives in one reusable place. Define relationship chains [#define-relationship-chains] Use `defineChain()` when a relationship should apply multiple joins together:
    ```typescript
    const relationships = new JoinRelationships();

    relationships.defineChain('orderCustomerRegion', [
      {
        from: 'orders',
        to: 'users',
        leftColumn: 'user_id',
        rightColumn: 'id',
        type: 'INNER',
      },
      {
        from: 'users',
        to: 'regions',
        leftColumn: 'region_id',
        rightColumn: 'id',
        type: 'LEFT',
      },
    ]);
    ```
  
Then apply the whole chain in one call:
    ```typescript
    const rows = await db
      .table('orders')
      .withRelation('orderCustomerRegion')
      .select([
        'orders.id',
        'users.name',
        'regions.region_name',
      ])
      .execute();
    ```
  
Override join options per query [#override-join-options-per-query] You can override the join type without redefining the relationship:
    ```typescript
    const rows = await db
      .table('orders')
      .withRelation('orderCustomer', { type: 'INNER' })
      .select([
        'orders.id',
        'users.name',
      ])
      .execute();
    ```
  
This is useful when the shared relationship is usually `LEFT`, but one query needs stricter matching. `alias` can be defined on the relationship itself for SQL generation. * For string-based registry lookups such as `withRelation('orderCustomer')`, TypeScript cannot inspect the stored relationship shape, so alias/table widening is runtime-only. * For direct `JoinPath` usage, `withRelation()` does widen the builder type for joined tables and aliases. * Inline joins are still a good choice for one-off joins, but direct `JoinPath` usage is fully typed when you want reusable path objects with compile-time widening. Alias override is only supported for single-step relationships. For `defineChain()` relationships, define aliases on the chain steps themselves if needed. Typed direct-path usage [#typed-direct-path-usage] If you want `withRelation()` to widen the builder type for joined-table or aliased column selection, pass a `JoinPath` directly instead of looking it up by string name:
    ```typescript
    import type { JoinPath } from '@hypequery/clickhouse';

    const orderCustomerPath = {
      from: 'orders',
      to: 'users',
      leftColumn: 'user_id',
      rightColumn: 'id',
      alias: 'customer',
    } as const satisfies JoinPath;

    const rows = await db
      .table('orders')
      .withRelation(orderCustomerPath)
      .select([
        'orders.id',
        'customer.name',
      ])
      .execute();
    ```
  
String-based registry lookups remain convenient for reuse, but their widening is runtime-only because the relationship name is not available to TypeScript. Initialization requirements [#initialization-requirements] Call `createQueryBuilder.setJoinRelationships(...)` before using `withRelation()`. If relationships are not registered, hypequery throws:
    ```text
    Join relationships have not been initialized. Call QueryBuilder.setJoinRelationships first.
    ```
  
If a relationship name does not exist, hypequery throws:
    ```text
    Join relationship 'orderCustomer' not found
    ```
  
Best practices [#best-practices] * Register relationships once during app startup, not inside query functions. * Use semantic names such as `orderCustomer` or `orderCustomerRegion`, not generic names like `join1`. * Prefer inline joins for one-off queries and relationships for stable, reused join paths. * Keep chains small and intentional so query behavior stays readable. See Also [#see-also] * [Joins](/docs/query-building/joins) * [Subqueries & CTEs](/docs/query-building/subqueries-ctes) * [Query Builder API](/docs/reference/api/query-builder) --- # Joins (/docs/query-building/joins) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; Joins [#joins] Joins combine data from multiple tables based on related columns. hypequery provides type-safe joins with full TypeScript support for joined columns. Overview [#overview] Use joins to: * Combine related data from multiple tables * Enrich queries with additional context * Avoid N+1 query problems * Maintain type safety across tables These examples use a typed standalone `db` client so the query builder stays the focus. Join Types [#join-types] | Join Type | Method | Description | | ------------------- | ----------------- | ------------------------------------------------------------ | | **INNER JOIN** | `innerJoin()` | Returns rows when both tables have matches | | **LEFT JOIN** | `leftJoin()` | Returns all rows from left table, matched rows from right | | **RIGHT JOIN** | `rightJoin()` | Returns all rows from right table, matched rows from left | | **FULL JOIN** | `fullJoin()` | Returns all rows when there's a match in either table | | **ARRAY JOIN** | `arrayJoin()` | Expands array values into multiple rows | | **LEFT ARRAY JOIN** | `leftArrayJoin()` | Expands array values while preserving rows with empty arrays | Inner Join [#inner-join] Returns only rows where both tables have matching values:
    ```ts
    const results = await db.table('orders')
      .innerJoin('users', 'user_id', 'users.id')
      .select([
        'orders.id',
        'orders.total',
        'users.name AS customer_name',
        'users.email AS customer_email',
      ])
      .execute();
    ```
  
* You only want records with matches in both tables * The relationship is required * You want to filter out unmatched records Left Join [#left-join] Returns all rows from the left table, and matched rows from the right table (NULL if no match): const results = await db.table('users') .leftJoin('orders', 'id', 'orders.user\_id') .select(\[ 'users.id', 'users.name', 'orders.id AS last\_order\_id', 'orders.total AS last\_order\_total', ]) .execute(); * You want all records from the primary table * The relationship is optional * You need to preserve unmatched records Right Join [#right-join] Returns all rows from the right table, and matched rows from the left table: ```typescript const results = await db.table('orders') .rightJoin('users', 'user_id', 'users.id') .select([ 'orders.id', 'users.name', 'users.email', ]) .execute(); ``` Full Join [#full-join] Returns all rows when there's a match in either table: ```typescript const results = await db.table('employees') .fullJoin('departments', 'dept_id', 'departments.id') .select([ 'employees.name AS employee', 'departments.name AS department', ]) .execute(); ``` Array Join [#array-join] Use `arrayJoin()` when a ClickHouse array column should expand into one row per element. ```typescript const results = await db.table('events') .select(['id', 'tags']) .arrayJoin('tags') .execute(); ``` This maps directly to ClickHouse `ARRAY JOIN`. `arrayJoin()` and `leftArrayJoin()` are intended for array-valued columns. The type system now enforces that more strictly, including for joined and aliased columns. Left Array Join [#left-array-join] Use `leftArrayJoin()` when you want array expansion but still need rows with empty arrays to stay in the result. ```typescript const results = await db.table('events') .select(['id', 'tags']) .leftArrayJoin('tags') .execute(); ``` This maps directly to ClickHouse `LEFT ARRAY JOIN`. Join Syntax [#join-syntax] Basic Join [#basic-join] ```typescript db.table('table_name') .joinType('other_table', 'left_column', 'right_table.right_column') ``` * `joinType`: One of `innerJoin`, `leftJoin`, `rightJoin`, `fullJoin` * `other_table`: Name of the table to join * `left_column`: Column from the current table * `right_column`: Column from the joined table (format: `'table.column'`) With Table Alias [#with-table-alias] ```typescript await db.table('orders') .innerJoin('users', 'user_id', 'users.id', 'u') .select([ 'orders.id', 'u.name AS customer_name', ]) .execute(); ``` Type Safety with Joins [#type-safety-with-joins] TypeScript only exposes columns from a joined table after you register the join. Call `leftJoin('users', ...)` before referencing `users.email` in `select`, `where`, etc. Incorrect Order [#incorrect-order] ```typescript // ❌ Error - users table not joined yet await db.table('orders') .select(['orders.id', 'users.name']) // TypeScript error .leftJoin('users', 'user_id', 'users.id') .execute(); ``` Correct Order [#correct-order] ```typescript // ✅ Correct - join first, then select await db.table('orders') .leftJoin('users', 'user_id', 'users.id') .select(['orders.id', 'users.name']) // TypeScript OK .execute(); ``` TypeScript Knows Joined Columns [#typescript-knows-joined-columns] ```typescript const results = await db.table('orders') .leftJoin('users', 'user_id', 'users.id') .select([ 'orders.id', 'orders.total', 'users.name', 'users.email', ]) .execute(); // TypeScript knows result type includes: // - orders.id (number) // - orders.total (number) // - users.name (string) // - users.email (string) ``` Multiple Joins [#multiple-joins] Chain multiple joins to combine data from several tables: ```typescript const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .leftJoin('products', 'product_id', 'products.id') .leftJoin('categories', 'category_id', 'categories.id') .select([ 'orders.id AS order_id', 'users.name AS customer_name', 'products.name AS product_name', 'categories.name AS category_name', ]) .execute(); ``` Filtering with Joins [#filtering-with-joins] Where on Joined Columns [#where-on-joined-columns] ```typescript const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .where('users.country', 'eq', 'US') .where('orders.total', 'gte', 100) .select([ 'orders.id', 'users.name', 'orders.total', ]) .execute(); ``` Where Groups with Joins [#where-groups-with-joins] ```typescript const results = await db.table('orders') .leftJoin('users', 'user_id', 'users.id') .whereGroup((builder) => { builder .where('users.country', 'eq', 'US') .orWhere('users.country', 'eq', 'CA'); }) .where('orders.status', 'eq', 'completed') .select([ 'orders.id', 'users.name', 'users.country', ]) .execute(); ``` Joining on Multiple Conditions [#joining-on-multiple-conditions] ClickHouse doesn't natively support multiple join conditions in the same join. Use `where` clauses to add additional conditions: ```typescript const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .where('users.status', 'eq', 'active') .where('orders.created_at', 'gte', '2024-01-01') .select([ 'orders.id', 'users.name', ]) .execute(); ``` Common Patterns [#common-patterns] Orders with User Details [#orders-with-user-details] ```typescript const orders = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .select([ 'orders.id', 'orders.total', 'orders.created_at', 'users.name AS customer_name', 'users.email AS customer_email', ]) .orderBy('orders.created_at', 'DESC') .limit(50) .execute(); ``` Products with Categories [#products-with-categories] ```typescript const products = await db.table('products') .leftJoin('categories', 'category_id', 'categories.id') .select([ 'products.id', 'products.name', 'products.price', 'categories.name AS category_name', ]) .where('products.in_stock', 'eq', true) .execute(); ``` Events with Session Data [#events-with-session-data] ```typescript const events = await db.table('events') .innerJoin('sessions', 'session_id', 'sessions.id') .innerJoin('users', 'user_id', 'users.id') .select([ 'events.id', 'events.type', 'events.data', 'sessions.started_at AS session_start', 'users.name AS user_name', ]) .where('events.created_at', 'gte', '2024-01-01') .execute(); ``` All Users with Their Orders (or NULL) [#all-users-with-their-orders-or-null] ```typescript const results = await db.table('users') .leftJoin('orders', 'id', 'orders.user_id') .select([ 'users.id', 'users.name', 'orders.id AS last_order_id', 'orders.total AS last_order_total', ]) .orderBy('users.id', 'ASC') .execute(); ``` Performance Considerations [#performance-considerations] * Inner joins are typically faster than left joins * Join on indexed columns when possible * Filter before joining when you can * Consider denormalizing for frequently-joined data Filter Before Joining [#filter-before-joining] ```typescript // ✅ Better - filter first const results = await db.table('orders') .where('orders.status', 'eq', 'completed') .where('orders.total', 'gte', 100) .innerJoin('users', 'user_id', 'users.id') .select(['orders.*', 'users.name']) .execute(); // ❌ Worse - join all then filter const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .where('orders.status', 'eq', 'completed') .where('orders.total', 'gte', 100) .select(['orders.*', 'users.name']) .execute(); ``` Examples [#examples] E-commerce Order Details [#e-commerce-order-details] ```typescript const orderDetails = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .innerJoin('products', 'product_id', 'products.id') .select([ 'orders.id AS order_id', 'orders.quantity', 'orders.total', 'users.name AS customer_name', 'users.email AS customer_email', 'products.name AS product_name', 'products.price AS unit_price', ]) .where('orders.created_at', 'gte', '2024-01-01') .orderBy('orders.created_at', 'DESC') .execute(); ``` Analytics with User Segments [#analytics-with-user-segments] ```typescript const analytics = await db.table('events') .leftJoin('users', 'user_id', 'users.id') .leftJoin('segments', 'segment_id', 'segments.id') .select([ 'events.type', 'segments.name AS user_segment', selectExpr('count()', 'event_count'), ]) .groupBy(['events.type', 'user_segment']) .execute(); ``` --- # Ordering (/docs/query-building/ordering) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Ordering [#ordering] Control the order and quantity of results with sorting, limiting, and pagination tools. Overview [#overview] Use ordering to: * Sort results by one or more columns * Limit the number of returned rows * Paginate through large datasets * Remove duplicates with distinct These examples use a typed standalone `db` client so the query builder stays the focus. Order By [#order-by] Sort results by column values in ascending or descending order. Single Column Sort [#single-column-sort] ```typescript const users = await db.table('users') .select(['id', 'name', 'created_at']) .orderBy('created_at', 'DESC') .execute(); ``` Ascending Order [#ascending-order] ```typescript const products = await db.table('products') .select(['id', 'name', 'price']) .orderBy('price', 'ASC') .execute(); ``` If you don't specify a direction, `orderBy` defaults to ascending (`'ASC'`). Multiple Column Sort [#multiple-column-sort] Chain multiple `orderBy()` calls for secondary sorting: ```typescript const users = await db.table('users') .select(['id', 'name', 'country', 'created_at']) .orderBy('country', 'ASC') .orderBy('created_at', 'DESC') .execute(); // Sorts by country first, then by created_at within each country ``` Sort by Joined Columns [#sort-by-joined-columns] ```typescript const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .select([ 'orders.id', 'users.name', 'orders.total', ]) .orderBy('users.name', 'ASC') .orderBy('orders.total', 'DESC') .execute(); ``` Sort Direction Options [#sort-direction-options] | Direction | Description | Example | | --------- | ------------------------------ | ------------------------------- | | `'ASC'` | Ascending (lowest to highest) | `orderBy('price', 'ASC')` | | `'DESC'` | Descending (highest to lowest) | `orderBy('created_at', 'DESC')` | Limit [#limit] Restrict the number of rows returned: ```typescript const latestUsers = await db.table('users') .select(['id', 'name', 'created_at']) .orderBy('created_at', 'DESC') .limit(10) .execute(); ``` Limit By [#limit-by] `limitBy()` applies ClickHouse `LIMIT ... BY ...`, which limits rows per grouping key instead of limiting the whole result set. ```typescript const latestOrderPerUser = await db.table('orders') .select(['user_id', 'id', 'created_at']) .orderBy('created_at', 'DESC') .limitBy(1, 'user_id') .execute(); ``` You can also limit by multiple columns: ```typescript const results = await db.table('orders') .select(['user_id', 'status', 'id']) .orderBy('created_at', 'DESC') .limitBy(2, ['user_id', 'status']) .execute(); ``` `limitBy()` is applied before the final `limit()` if you use both. Always use `limit()` with `orderBy()` for predictable results. Without `orderBy()`, the rows returned may vary. Limit with Aggregations [#limit-with-aggregations] ```typescript const topCategories = await db.table('orders') .innerJoin('products', 'product_id', 'products.id') .select(['products.category']) .sum('orders.total', 'revenue') .groupBy(['products.category']) .orderBy('revenue', 'DESC') .limit(10) .execute(); ``` Offset [#offset] Skip a specified number of rows before returning results: ```typescript const page2 = await db.table('users') .select(['id', 'name']) .orderBy('id', 'ASC') .limit(20) .offset(20) // Skip first 20 rows .execute(); ``` Large offsets can be slow on big datasets. Consider keyset pagination for better performance. Pagination [#pagination] Combine `limit()` and `offset()` for pagination: Basic Pagination [#basic-pagination] ```typescript async function getUsers(page: number, pageSize: number) { const offset = (page - 1) * pageSize; return await db.table('users') .select(['id', 'name', 'email']) .orderBy('id', 'ASC') .limit(pageSize) .offset(offset) .execute(); } // Page 1 const page1 = await getUsers(1, 20); // Page 2 const page2 = await getUsers(2, 20); ``` Keyset Pagination (More Efficient) [#keyset-pagination-more-efficient] Instead of offset, use the last seen value for better performance: ```typescript async function getUsersAfter(lastId: number, limit: number) { return await db.table('users') .select(['id', 'name', 'email']) .where('id', 'gt', lastId) .orderBy('id', 'ASC') .limit(limit) .execute(); } // First page const page1 = await getUsersAfter(0, 20); const lastId = page1[page1.length - 1].id; // Next page const page2 = await getUsersAfter(lastId, 20); ``` * **Small datasets**: Use offset/limit (simple) * **Large datasets**: Use keyset pagination (faster) * **Real-time**: Use cursor-based pagination with time-based columns Distinct [#distinct] Remove duplicate rows from results: ```typescript const countries = await db.table('users') .select(['country']) .distinct() .orderBy('country', 'ASC') .execute(); ``` Distinct with Multiple Columns [#distinct-with-multiple-columns] ```typescript const uniqueCombinations = await db.table('orders') .select(['country', 'status']) .distinct() .execute(); ``` Distinct with Aggregations [#distinct-with-aggregations] ```typescript const stats = await db.table('events') .select(['user_id']) .countDistinct('event_type', 'unique_events') .groupBy(['user_id']) .orderBy('unique_events', 'DESC') .limit(10) .execute(); ``` Common Patterns [#common-patterns] Latest Records [#latest-records] ```typescript const latestOrders = await db.table('orders') .select(['id', 'user_id', 'total', 'created_at']) .orderBy('created_at', 'DESC') .limit(50) .execute(); ``` Top N by Value [#top-n-by-value] ```typescript const topSpenders = await db.table('orders') .select(['user_id']) .sum('total', 'lifetime_value') .groupBy(['user_id']) .orderBy('lifetime_value', 'DESC') .limit(100) .execute(); ``` Most Recent Items Per User [#most-recent-items-per-user] ```typescript const latestEvents = await db.table('events') .select(['user_id']) .max('created_at', 'last_event') .groupBy(['user_id']) .orderBy('last_event', 'DESC') .limit(50) .execute(); ``` Random Sample [#random-sample] ```typescript const sample = await db.table('users') .select(['id', 'name']) .orderBy('rand()', 'ASC') .limit(100) .execute(); ``` Unique Values in Column [#unique-values-in-column] ```typescript const countries = await db.table('users') .select(['country']) .where('country', 'isNotNull') .distinct() .orderBy('country', 'ASC') .execute(); ``` Sort by Expression [#sort-by-expression] ```typescript const results = await db.table('products') .select(['id', 'name', 'price', 'discount']) .orderBy(selectExpr('price * (1 - discount)'), 'ASC') .execute(); ``` Multiple Sorting Strategies [#multiple-sorting-strategies] Primary and Secondary Sort [#primary-and-secondary-sort] ```typescript const results = await db.table('orders') .select(['id', 'status', 'total', 'created_at']) .orderBy('status', 'ASC') .orderBy('total', 'DESC') .orderBy('created_at', 'ASC') .execute(); // Sorts by status first, then by total within each status, // then by created_at within same total ``` Conditional Sorting [#conditional-sorting] ```typescript const results = await db.table('products') .select(['id', 'name', 'price', 'stock']) .orderBy(selectExpr('if(stock > 0, 0, 1)'), 'ASC') // In stock first .orderBy('price', 'ASC') .execute(); ``` Type Safety [#type-safety] TypeScript ensures you sort by valid columns: ```typescript // ✅ Valid - column exists await db.table('users') .orderBy('created_at', 'DESC') .execute(); // ❌ Error - column doesn't exist await db.table('users') .orderBy('invalid_column', 'ASC') // TypeScript error .execute(); // ✅ Valid - can sort by joined columns after joining await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .orderBy('users.name', 'ASC') .execute(); // ❌ Error - can't sort by unjoined table await db.table('orders') .orderBy('users.name', 'ASC') // TypeScript error .execute(); ``` Performance Considerations [#performance-considerations] * Sorting requires reading all matching rows * Use `limit()` to reduce work after sorting * Create indexes on frequently-sorted columns * Consider `final()` for ClickHouse-specific optimizations Efficient Large Dataset Pagination [#efficient-large-dataset-pagination] ```typescript // ❌ Slow with large offsets const page1000 = await db.table('events') .orderBy('created_at', 'DESC') .limit(20) .offset(20000) // Skips 20,000 rows .execute(); // ✅ Faster - use keyset pagination const lastTimestamp = '2024-01-15 10:00:00'; const page1000 = await db.table('events') .where('created_at', 'lt', lastTimestamp) .orderBy('created_at', 'DESC') .limit(20) .execute(); ``` Examples [#examples] Leaderboard [#leaderboard] ```typescript const leaderboard = await db.table('game_scores') .select(['user_id']) .sum('score', 'total_score') .count('id', 'games_played') .groupBy(['user_id']) .orderBy('total_score', 'DESC') .limit(100) .execute(); ``` Recent Activity Feed [#recent-activity-feed] ```typescript const activityFeed = await db.table('events') .select(['id', 'type', 'user_id', 'created_at']) .orderBy('created_at', 'DESC') .limit(50) .execute(); ``` Browse Products with Pagination [#browse-products-with-pagination] ```typescript async function browseProducts(category: string, page: number) { const pageSize = 24; const offset = (page - 1) * pageSize; return await db.table('products') .select(['id', 'name', 'price', 'image_url']) .where('category', 'eq', category) .where('in_stock', 'eq', true) .orderBy('price', 'ASC') .limit(pageSize) .offset(offset) .execute(); } ``` Unique Email Domains [#unique-email-domains] ```typescript const domains = await db.table('users') .select([selectExpr('splitByChar(\\'@\\', email)[2]', 'domain')]) .distinct() .orderBy('domain', 'ASC') .execute(); ``` --- # Select (/docs/query-building/select) import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; Select [#select] The `select()` method specifies which columns to return from your query. hypequery provides type-safe column selection with support for aliases, expressions, and helper functions. Overview [#overview] Select is typically the first method you chain after `table()`. It determines: * Which columns are returned * How columns are named (aliases) * What expressions are calculated * The TypeScript type of the result These examples use a typed standalone `db` client so the query builder stays the focus. Basic Column Selection [#basic-column-selection] Select Specific Columns [#select-specific-columns] ```typescript const users = await db.table('users') .select(['id', 'name', 'email']) .execute(); // Returns: Array<{ id: number; name: string; email: string }> ``` Select All Columns [#select-all-columns] ```typescript const users = await db.table('users') .select('*') .execute(); // Returns all columns from the users table ``` Avoid `select('*')` in production. Explicitly list only the columns you need for better performance and clarity. Select from Joined Tables [#select-from-joined-tables] ```typescript const orders = await db.table('orders') .leftJoin('users', 'user_id', 'users.id') .select([ 'orders.id', 'orders.total', 'users.email AS customer_email', ]) .execute(); ``` You can only select from joined tables after registering the join. TypeScript ensures the join exists before you can reference its columns. Column Aliases [#column-aliases] Rename columns using the `AS` keyword: ```typescript const users = await db.table('users') .select([ 'id', 'name AS full_name', 'email AS contact_email', ]) .execute(); ``` This is useful for: * Disambiguating columns with the same name * Making column names more descriptive * Following naming conventions Select Expressions [#select-expressions] Using Helper Functions [#using-helper-functions] hypequery provides helper functions for common ClickHouse expressions: ```typescript import { rawAs, selectExpr } from '@hypequery/clickhouse'; const users = await db.table('users') .select([ 'id', 'name', rawAs('status', 'account_status'), selectExpr('length(email)', 'email_length'), ]) .execute(); ``` Built-in Select Helpers [#built-in-select-helpers] | Helper | Description | Example | | ------------------------------- | ------------------------------- | ------------------------------------ | | `rawAs(column, alias)` | Raw column reference with alias | `rawAs('status', 'account_status')` | | `selectExpr(expression, alias)` | SQL expression with alias | `selectExpr('col1 + col2', 'total')` | Select with Expressions [#select-with-expressions] ```typescript const events = await db.table('events') .select([ 'id', selectExpr('arrayJoin(tags)', 'tag'), selectExpr('count()', 'occurrences'), ]) .execute(); ``` Common Patterns [#common-patterns] Conditional Selection [#conditional-selection] ```typescript const columns = includeEmail ? ['id', 'name', 'email'] : ['id', 'name']; const users = await db.table('users') .select(columns) .execute(); ``` Dynamic Column Building [#dynamic-column-building] ```typescript const selectedColumns = [ 'id', 'name', ...additionalColumns, ]; const users = await db.table('users') .select(selectedColumns) .execute(); ``` Select with Calculations [#select-with-calculations] ```typescript const orders = await db.table('orders') .select([ 'id', selectExpr('quantity * price', 'total'), selectExpr('quantity * price * 0.1', 'tax'), ]) .execute(); ``` Type Safety [#type-safety] TypeScript ensures that selected columns exist in your schema: ```typescript // ✅ Valid - columns exist db.table('users') .select(['id', 'name', 'email']) .execute(); // ❌ Error - 'invalid_column' doesn't exist db.table('users') .select(['id', 'invalid_column']) .execute(); // ❌ Error - can't select from unjoined table db.table('orders') .select(['orders.id', 'users.name']) // Error: users not joined .execute(); ``` Result Type Inference [#result-type-inference] The TypeScript return type is automatically inferred based on your select: ```typescript // Type is inferred as: // Promise> const users = await db.table('users') .select(['id', 'name', 'email']) .execute(); ``` When using aliases, the result type uses the alias name: ```typescript const users = await db.table('users') .select([ 'id', 'name AS full_name', ]) .execute(); // Result has 'full_name' not 'name' ``` Examples [#examples] Select Specific Columns [#select-specific-columns-1] ```typescript const users = await db.table('users') .select(['id', 'name', 'email']) .where('status', 'eq', 'active') .execute(); ``` Select with Aliases [#select-with-aliases] ```typescript const results = await db.table('events') .select([ 'user_id', selectExpr('count()', 'event_count'), 'event_type AS type', ]) .groupBy(['user_id', 'type']) .execute(); ``` Select from Multiple Tables [#select-from-multiple-tables] ```typescript const results = await db.table('orders') .innerJoin('users', 'user_id', 'users.id') .leftJoin('products', 'product_id', 'products.id') .select([ 'orders.id AS order_id', 'users.name AS customer_name', 'products.name AS product_name', 'orders.total', ]) .execute(); ``` --- # SQL Expressions (/docs/query-building/sql-expressions) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; The fluent query builder covers most workflows, but you can always run raw SQL or use expression helpers for complex cases. This page shows when and how to use them. These examples use a typed standalone `db` client so the query builder stays the focus. Raw SQL helpers are escape hatches for developer-authored SQL. Do not expose them as a public query language or build raw fragments from user or agent input. For public analytics input, expose semantic dataset or metric APIs instead. See [Trust Boundaries](/docs/reference/trust-boundaries). Raw SQL Queries [#raw-sql-queries] Execute raw SQL when you need complete control: ```typescript const results = await db.rawQuery<{ day: string; signups: number }>( `SELECT toStartOfDay(created_at) AS day, count(*) AS signups FROM signups WHERE account_id = ? GROUP BY day ORDER BY day`, [accountId] ); ``` **Standalone:** ```typescript const results = await db.rawQuery(sql, [accountId]); ``` Expression Helpers [#expression-helpers] When the builder handles most of the query but you need a custom expression, use `raw`, `rawAs`, or `selectExpr`. These helpers keep your queries type-safe. raw() – create a SQL expression [#raw--create-a-sql-expression] Use `raw()` when you need an expression the fluent builder does not model directly: ```typescript import { raw } from '@hypequery/clickhouse'; db.table('sensors') .select(['id', 'reading']) .where(raw('toDate(recorded_at)'), 'eq', '2024-01-01') ``` * Accepts a SQL fragment string and returns a typed expression object * Treat as an escape hatch; TypeScript cannot validate the expression * Use for computed projections or predicate expressions the fluent API doesn't support rawAs() – expression + alias [#rawas--expression--alias] Use `rawAs()` when you need a SQL expression with a typed alias: ```typescript import { rawAs } from '@hypequery/clickhouse'; db.table('orders') .select([ 'account_id', rawAs('SUM(total)', 'total_revenue'), rawAs('COUNT(*)', 'order_count'), ]) .groupBy(['account_id']) .having('SUM(total) > 1000') ``` Common patterns: | Pattern | Example | | --------------- | ------------------------------------------------------------ | | Aggregations | `rawAs('AVG(duration)', 'avg_duration')` | | CASE statements | `rawAs('CASE WHEN age < 18 THEN 1 ELSE 0 END', 'is_minor')` | | JSON extraction | `rawAs("JSONExtractString(metadata, 'country')", 'country')` | selectExpr() – shorthand for select + alias [#selectexpr--shorthand-for-select--alias] Use `selectExpr()` for computed columns in your SELECT clause: ```typescript import { selectExpr } from '@hypequery/clickhouse'; db.table('rides') .select([ selectExpr('toStartOfWeek(start_time)', 'week'), selectExpr('count()', 'ride_count'), ]) .groupBy(['week']) .orderBy('week', 'ASC') ``` * Behaves like `raw` when no alias is supplied and like `rawAs` when you provide one * Ideal when your entire projection consists of expressions Built-in function helpers [#built-in-function-helpers] Helpers like `toDateTime`, `formatDateTime`, `toStartOfInterval`, the built-in `toStartOf*` helpers, and `datePart` wrap common ClickHouse functions with type safety: ```typescript import { toDateTime, formatDateTime } from '@hypequery/clickhouse'; db.table('events') .select([ 'id', toDateTime('occurred_at', 'event_ts'), formatDateTime('occurred_at', 'Y-MM-dd', { alias: 'event_date' }), ]) .where('event_type', 'eq', 'purchase') ``` The built-in time-bucketing helpers are also exported directly from `@hypequery/clickhouse`, for example `toStartOfMinute`, `toStartOfHour`, `toStartOfDay`, `toStartOfWeek`, `toStartOfMonth`, `toStartOfQuarter`, and `toStartOfYear`. Typing & Best Practices [#typing--best-practices] Type generics for expressions [#type-generics-for-expressions] Expression helpers accept generics to describe the result type: ```typescript db.table('orders') .select([ rawAs('SUM(total)', 'total_revenue'), rawAs('AVG(total)', 'avg_order'), ]) ``` The alias (`'total_revenue'`) shows up on the result type with the specified generic (`number`). Without an alias, the builder can't add a strongly typed key, so always alias computed columns. Type raw queries [#type-raw-queries] `rawQuery` returns `unknown[]` by default. Pass a type argument for type safety: ```typescript const rows = await db.rawQuery<{ day: string; signups: number }>(sql, [accountId]); ``` Best practices [#best-practices] * **Keep raw SQL static**: Build raw expressions from trusted SQL snippets rather than interpolating user input * **Prefer fluent API**: Use expression helpers only when the fluent API doesn't support your use case * **Alias all expressions**: Computed columns need aliases for type safety * **Type raw queries**: Always provide type arguments to `rawQuery()` * **Use semantic APIs for runtime input**: Dataset, metric, Serve, MCP, and generated tool schemas are the constrained public surfaces --- # Subqueries & CTEs (/docs/query-building/subqueries-ctes) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Subqueries & CTEs [#subqueries--ctes] hypequery supports typed subqueries in the `FROM` clause, Common Table Expressions (CTEs), and raw SQL escape hatches for complex queries. These examples use a typed standalone `db` client so the query builder stays the focus. Subqueries in FROM [#subqueries-in-from] Pass a query builder to `db.from()` to use its result as the source of another query. The outer builder only exposes columns produced by the inner query, including aggregate aliases. ```typescript import { rawAs } from '@hypequery/clickhouse'; const totalsByUser = db .table('orders') .where('created_at', 'gte', '2026-06-06') .select(['account_id', 'user_id']) .sum('amount', 'total_amount') .groupBy(['account_id', 'user_id']); const totalsByAccount = await db .from(totalsByUser) .select([ 'account_id', rawAs( 'sumIf(total_amount, total_amount < 0)', 'negative_total', ), rawAs( 'sumIf(total_amount, total_amount > 0)', 'positive_total', ), ]) .groupBy('account_id') .execute(); ``` The generated SQL nests the inner builder directly in the `FROM` clause. Parameters remain bound rather than being interpolated, and their order is preserved across the inner and outer queries. Typed methods on the outer builder reject source-table columns that the inner query did not select. SQL inside `rawAs()` remains an explicit trusted expression; provide its result type when you want the alias reflected precisely in the returned row type. Common Table Expressions (CTEs) [#common-table-expressions-ctes] CTEs are temporary result sets that you can reference within a query. They help make complex queries more readable and maintainable. Using QueryBuilder as a CTE [#using-querybuilder-as-a-cte] You can use another QueryBuilder instance as a CTE: ```typescript const activeUsersSubquery = db .table('users') .select(['id', 'user_name', 'email']) .where('status', 'eq', 'active'); const results = await db .table('orders') .withCTE('active_users', activeUsersSubquery) .select([ 'orders.id', 'orders.total', 'active_users.user_name' ]) .innerJoin('active_users', 'user_id', 'active_users.id') .execute(); ``` This will generate SQL similar to: ```sql WITH active_users AS ( SELECT id, user_name, email FROM users WHERE status = 'active' ) SELECT orders.id, orders.total, active_users.user_name FROM orders INNER JOIN active_users ON orders.user_id = active_users.id ``` Because the CTE was built from a query builder, hypequery knows which columns it exposes. The alias becomes a typed join target: `active_users.id` and `active_users.user_name` are checked, and `active_users.user_name` carries the inner query's type through to the result row. Values inside a builder CTE stay bound as query parameters rather than being escaped into the SQL string, exactly as they are in the outer query. A CTE join does not take the trailing table-alias argument that a schema-table join accepts — aliases are resolved through the schema, and a CTE has no schema entry. Reference the CTE by its own name instead. Using Raw SQL as a CTE [#using-raw-sql-as-a-cte] For more complex subqueries, you can use raw SQL strings: ```typescript const results = await db .table('orders') .withCTE( 'monthly_totals', 'SELECT user_id, toStartOfMonth(created_at) as month, SUM(total) as monthly_sum FROM orders GROUP BY user_id, month', { user_id: 'UInt64', month: 'Date', monthly_sum: 'Float64' } ) .innerJoin('monthly_totals', 'user_id', 'monthly_totals.user_id') .select([ 'orders.id', 'orders.created_at', 'monthly_totals.monthly_sum' ]) .execute(); ``` A raw SQL body is opaque to hypequery, so declare the columns it produces to get the same typing a builder CTE gets. The declaration uses the same column types as a generated schema, and it is an assertion: nothing checks it against the SQL, so a wrong declaration surfaces at runtime rather than at compile time. Without the third argument the CTE still works and still renders, but its alias stays untyped and cannot be used as a join target. A runtime alias typed as `string` also stays untyped; use a literal alias when you need typed joins. Multiple CTEs [#multiple-ctes] You can chain multiple CTEs for complex analytics: ```typescript const results = await db .table('events') .withCTE( 'daily_users', 'SELECT user_id, toDate(timestamp) as day, COUNT(*) as event_count FROM events GROUP BY user_id, day' ) .withCTE( 'active_users', 'SELECT user_id, COUNT(DISTINCT day) as active_days FROM daily_users GROUP BY user_id HAVING active_days > 7', { user_id: 'UInt64', active_days: 'UInt64' } ) .innerJoin('active_users', 'user_id', 'active_users.user_id') .select(['events.*']) .execute(); ``` Scalar WITH Aliases [#scalar-with-aliases] ClickHouse also supports scalar expressions in the `WITH` clause. Use `withScalar()` when you want to define an expression once and reuse it in `SELECT`, `WHERE`, and `ORDER BY`. ```typescript const results = await db .table('orders') .withScalar('user_name', expr => expr.ch.dictGet('users_dict', 'name', expr.col('user_id')) ) .select(['order_id', 'amount', 'user_name']) .where('user_name', 'like', '%Alice%') .orderBy('user_name', 'ASC') .limit(50) .execute(); ``` This generates: ```sql WITH dictGet('users_dict', 'name', user_id) AS user_name SELECT order_id, amount, user_name FROM orders WHERE user_name LIKE '%Alice%' ORDER BY user_name ASC LIMIT 50 ``` Raw SQL Expressions [#raw-sql-expressions] For complex conditions that can't be expressed using the fluent API, you can use raw SQL expressions: HAVING Clauses [#having-clauses] The `raw()` method allows you to add custom conditions to the HAVING clause: ```typescript const results = await db .table('orders') .select(['user_id']) .sum('total', 'total_spent') .groupBy(['user_id']) .raw('SUM(total) > 1000') .raw('COUNT(DISTINCT product_id) >= 3') .execute(); ``` This will generate: ```sql SELECT user_id, SUM(total) AS total_spent FROM orders GROUP BY user_id HAVING SUM(total) > 1000 AND COUNT(DISTINCT product_id) >= 3 ``` Limitations and Workarounds [#limitations-and-workarounds] hypequery doesn't directly support nested subqueries in WHERE clauses, but you can work around this with CTEs or raw SQL expressions: Example: IN Subqueries [#example-in-subqueries] To achieve a query like: ```sql SELECT * FROM products WHERE category_id IN (SELECT id FROM categories WHERE active = 1) ``` Use a CTE approach: ```typescript // Create a CTE for active categories const results = await db .table('products') .withCTE( 'active_categories', 'SELECT id FROM categories WHERE active = 1' ) .select(['products.*']) .innerJoin('active_categories', 'category_id', 'active_categories.id') .execute(); ``` --- # Time Functions (/docs/query-building/time-functions) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; hypequery provides powerful time-based functions for working with dates, timestamps, and time intervals. These are essential for analytics, reporting, and time-series data. All helpers shown on this page are public exports from `@hypequery/clickhouse`. Overview [#overview] Use time functions to: * Convert and format timestamps * Extract date components (year, month, day, etc.) * Group data by time intervals * Build time-series analytics These examples use a typed standalone `db` client so the query builder stays the focus. Time Conversion Functions [#time-conversion-functions] toDateTime [#todatetime] Convert values to ClickHouse DateTime objects: ```typescript const events = await db.table('events') .select([ 'id', toDateTime('created_at', 'event_ts'), ]) .execute(); ``` Use `toDateTime()` for standard timestamps (second precision). For microsecond precision, use DateTime64 columns directly in your schema. formatDateTime [#formatdatetime] Format timestamps with custom format strings: ```typescript const events = await db.table('events') .select([ 'id', formatDateTime('created_at', 'Y-MM-dd HH:00', { alias: 'hour_bucket', }), ]) .groupBy(['hour_bucket']) .execute(); ``` Format Codes [#format-codes] | Code | Description | Example | | ---- | ---------------------- | ------- | | `Y` | 4-digit year | 2024 | | `y` | 2-digit year | 24 | | `M` | Month number | 01-12 | | `MM` | Month number with zero | 01, 12 | | `d` | Day of month | 1-31 | | `dd` | Day of month with zero | 01, 31 | | `H` | Hour (24-hour) | 0-23 | | `HH` | Hour with zero | 00, 23 | | `m` | Minute | 0-59 | | `mm` | Minute with zero | 00, 59 | | `s` | Second | 0-59 | | `ss` | Second with zero | 00, 59 | Format Examples [#format-examples] ```typescript // ISO date format formatDateTime('created_at', 'Y-MM-dd', { alias: 'date' }) // Hour of day formatDateTime('created_at', 'Y-MM-dd HH:00', { alias: 'hour' }) // Custom readable format formatDateTime('created_at', 'Y-MM-dd HH:mm:ss', { alias: 'formatted' }) // Month formatDateTime('created_at', 'Y-MM', { alias: 'month' }) ``` Date Component Extraction [#date-component-extraction] datePart [#datepart] Extract specific components from a timestamp: ```typescript const events = await db.table('events') .select([ datePart('year', 'created_at', 'year'), datePart('month', 'created_at', 'month'), datePart('day', 'created_at', 'day'), datePart('hour', 'created_at', 'hour'), datePart('minute', 'created_at', 'minute'), ]) .execute(); ``` DatePart Options [#datepart-options] | Part | Description | Range | | --------- | ---------------- | --------- | | `year` | Year | 0000-9999 | | `quarter` | Quarter of year | 1-4 | | `month` | Month number | 1-12 | | `week` | Week number | 1-53 | | `day` | Day of month | 1-31 | | `hour` | Hour of day | 0-23 | | `minute` | Minute of hour | 0-59 | | `second` | Second of minute | 0-59 | DatePart Examples [#datepart-examples] ```typescript // Year-over-year comparison const yearlyStats = await db.table('orders') .select([ datePart('year', 'created_at', 'year'), ]) .sum('total', 'revenue') .groupBy(['year']) .orderBy('year', 'ASC') .execute(); // Day of week analysis const dailyStats = await db.table('events') .select([ datePart('day', 'created_at', 'day_of_month'), ]) .count('id', 'event_count') .groupBy(['day_of_month']) .orderBy('day_of_month', 'ASC') .execute(); ``` Time Interval Functions [#time-interval-functions] toStartOfInterval [#tostartofinterval] Truncate timestamps to specific intervals: ```typescript const events = await db.table('events') .select([ 'id', toStartOfInterval('created_at', '1 hour', 'hour_bucket'), toStartOfInterval('created_at', '15 minute', 'fifteen_min_bucket'), ]) .groupBy(['hour_bucket', 'fifteen_min_bucket']) .execute(); ``` Interval Formats [#interval-formats] | Interval | Description | | ----------- | ---------------------------- | | `1 minute` | Truncate to minute | | `5 minute` | Truncate to 5-minute window | | `15 minute` | Truncate to 15-minute window | | `1 hour` | Truncate to hour | | `6 hour` | Truncate to 6-hour window | | `1 day` | Truncate to day | | `1 week` | Truncate to week | | `1 month` | Truncate to month | | `1 quarter` | Truncate to quarter | | `1 year` | Truncate to year | Built-in start-of helpers [#built-in-start-of-helpers] If you already know the exact bucket you want, you can import the built-in helpers directly instead of using a string interval: ```typescript import { toStartOfMinute, toStartOfHour, toStartOfDay, toStartOfWeek, toStartOfMonth, toStartOfQuarter, toStartOfYear, } from '@hypequery/clickhouse'; const events = await db.table('events') .select([ toStartOfHour('created_at', 'hour_start'), toStartOfDay('created_at', 'day_start'), toStartOfMonth('created_at', 'month_start'), ]) .execute(); ``` Use these when you want a direct helper for a standard ClickHouse bucket without spelling out an interval string. Group By Time Intervals [#group-by-time-intervals] Built-in Intervals [#built-in-intervals] `groupByTimeInterval()` supports ClickHouse's built-in `toStartOf*` bucketing methods. The builder still expects a string interval argument, even when the selected method does not use it directly: ```typescript const events = await db.table('events') .count('id', 'event_count') .groupByTimeInterval('created_at', '1 minute', 'toStartOfMinute') .execute(); ``` Built-in Interval Options [#built-in-interval-options] | Function | Description | | ------------------ | ------------------------- | | `toStartOfMinute` | Truncate to minute | | `toStartOfHour` | Truncate to hour | | `toStartOfDay` | Truncate to day | | `toStartOfWeek` | Truncate to week (Monday) | | `toStartOfMonth` | Truncate to month | | `toStartOfQuarter` | Truncate to quarter | | `toStartOfYear` | Truncate to year | Custom Intervals [#custom-intervals] ```typescript const events = await db.table('events') .count('id', 'event_count') .groupByTimeInterval('created_at', '5 minute') .execute(); ``` Common Patterns [#common-patterns] Daily Active Users [#daily-active-users] ```typescript const dailyActive = await db.table('events') .select([ toStartOfInterval('created_at', '1 day', 'date'), ]) .countDistinct('user_id', 'daily_active_users') .groupBy(['date']) .orderBy('date', 'ASC') .execute(); ``` Hourly Event Counts [#hourly-event-counts] ```typescript const hourlyEvents = await db.table('events') .select([ toStartOfInterval('created_at', '1 hour', 'hour'), ]) .count('id', 'event_count') .groupBy(['hour']) .orderBy('hour', 'ASC') .execute(); ``` Monthly Revenue [#monthly-revenue] ```typescript const monthlyRevenue = await db.table('orders') .select([ toStartOfInterval('created_at', '1 month', 'month'), ]) .sum('total', 'revenue') .groupBy(['month']) .orderBy('month', 'ASC') .execute(); ``` Weekly Cohort Analysis [#weekly-cohort-analysis] ```typescript const cohorts = await db.table('users') .select([ toStartOfInterval('created_at', '1 week', 'cohort_week'), ]) .count('id', 'new_users') .groupBy(['cohort_week']) .orderBy('cohort_week', 'ASC') .execute(); ``` Events per 15-Minute Bucket [#events-per-15-minute-bucket] ```typescript const buckets = await db.table('events') .select([ toStartOfInterval('created_at', '15 minute', 'time_bucket'), ]) .count('id', 'event_count') .groupBy(['time_bucket']) .orderBy('time_bucket', 'ASC') .execute(); ``` Time-Based Filtering [#time-based-filtering] Date Range [#date-range] ```typescript const recentEvents = await db.table('events') .where('created_at', 'gte', '2024-01-01') .where('created_at', 'lt', '2024-02-01') .select(['id', 'type']) .execute(); ``` Last N Days [#last-n-days] ```typescript const last7Days = await db.table('events') .where('created_at', 'gte', selectExpr('today() - 7')) .select(['id', 'type', 'created_at']) .execute(); ``` Time of Day Filtering [#time-of-day-filtering] ```typescript const businessHours = await db.table('events') .where(datePart('hour', 'created_at'), 'gte', 9) .where(datePart('hour', 'created_at'), 'lt', 17) .select(['id', 'created_at']) .execute(); ``` Time Zone Support [#time-zone-support] formatDateTime with Timezone [#formatdatetime-with-timezone] ```typescript const events = await db.table('events') .select([ formatDateTime('created_at', 'Y-MM-dd HH:mm:ss', { timezone: 'America/New_York', alias: 'est_time', }), ]) .execute(); ``` Convert Timezone [#convert-timezone] ```typescript const events = await db.table('events') .select([ 'id', toDateTime('created_at', 'utc_time'), formatDateTime('created_at', 'Y-MM-dd HH:mm:ss', { timezone: 'Europe/London', alias: 'local_time', }), ]) .execute(); ``` Time-Series Analytics [#time-series-analytics] Funnel by Time Period [#funnel-by-time-period] ```typescript const funnel = await db.table('events') .select([ toStartOfInterval('created_at', '1 day', 'date'), 'event_type', ]) .countDistinct('user_id', 'unique_users') .groupBy(['date', 'event_type']) .orderBy(['date', 'event_type'], 'ASC') .execute(); ``` Moving Average [#moving-average] ```typescript const movingAvg = await db.table('events') .select([ toStartOfInterval('created_at', '1 day', 'date'), selectExpr('avg(count) OVER (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)', 'moving_avg'), ]) .count('id', 'count') .groupBy(['date']) .orderBy('date', 'ASC') .execute(); ``` Year-over-Year Comparison [#year-over-year-comparison] ```typescript const yoy = await db.table('orders') .select([ datePart('year', 'created_at', 'year'), datePart('month', 'created_at', 'month'), ]) .sum('total', 'revenue') .groupBy(['year', 'month']) .orderBy(['year', 'month'], 'ASC') .execute(); ``` Performance Tips [#performance-tips] * Prefer `toStartOfInterval(...)` or `groupByTimeInterval(...)` for explicit buckets * Consider materialized views for common time-based aggregations * Filter by time ranges before grouping when possible * Use appropriate partitioning by time for large tables Filter Before Grouping [#filter-before-grouping] ```typescript // ✅ Better - filter first const stats = await db.table('events') .where('created_at', 'gte', '2024-01-01') .select([ toStartOfInterval('created_at', '1 day', 'date'), ]) .count('id', 'event_count') .groupBy(['date']) .execute(); // ❌ Slower - group everything const stats = await db.table('events') .select([ toStartOfInterval('created_at', '1 day', 'date'), ]) .count('id', 'event_count') .groupBy(['date']) .having('date >= toDateTime(\'2024-01-01\')') .execute(); ``` Examples [#examples] Website Traffic by Hour [#website-traffic-by-hour] ```typescript const hourlyTraffic = await db.table('page_views') .select([ toStartOfInterval('created_at', '1 hour', 'hour'), ]) .count('id', 'page_views') .countDistinct('user_id', 'unique_visitors') .groupBy(['hour']) .orderBy('hour', 'ASC') .execute(); ``` Signup Trends [#signup-trends] ```typescript const signups = await db.table('users') .select([ toStartOfInterval('created_at', '1 week', 'week'), ]) .count('id', 'new_signups') .groupBy(['week']) .orderBy('week', 'ASC') .execute(); ``` Custom Reporting Period [#custom-reporting-period] ```typescript const reportPeriod = await db.table('orders') .select([ formatDateTime('created_at', 'Y-MM', { alias: 'period' }), ]) .sum('total', 'revenue') .count('id', 'order_count') .groupBy(['period']) .orderBy('period', 'ASC') .execute(); ``` --- # Where (/docs/query-building/where) import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; Where [#where] The `where()` method filters rows based on conditions. It's the most flexible clause in the query builder, supporting simple comparisons, complex predicates, and ClickHouse-specific operators. Overview [#overview] Use `where()` to: * Filter rows by column values * Chain multiple conditions (AND logic) * Build complex expressions with OR logic These examples use a typed standalone `db` client so the query builder stays the focus. * Use ClickHouse functions and operators * Work with arrays, tuples, and subqueries Basic Where Clauses [#basic-where-clauses] Simple Conditions [#simple-conditions] ```typescript const users = await db.table('users') .where('status', 'eq', 'active') .select(['id', 'name', 'email']) .execute(); ``` Multiple Conditions (AND) [#multiple-conditions-and] ```typescript const users = await db.table('users') .where('status', 'eq', 'active') .where('age', 'gte', 18) .where('country', 'eq', 'US') .select(['id', 'name']) .execute(); ``` Comparison Operators [#comparison-operators] | Operator | Description | Example | | --------- | --------------------- | ------------------------------------------------- | | `eq` | Equal | `where('status', 'eq', 'active')` | | `neq` | Not equal | `where('status', 'neq', 'deleted')` | | `gt` | Greater than | `where('age', 'gt', 18)` | | `gte` | Greater than or equal | `where('age', 'gte', 18)` | | `lt` | Less than | `where('price', 'lt', 100)` | | `lte` | Less than or equal | `where('price', 'lte', 100)` | | `like` | Pattern matching | `where('email', 'like', '%@company.com')` | | `notLike` | Not like pattern | `where('email', 'notLike', '%@spam.com')` | | `in` | In array | `where('status', 'in', ['active', 'pending'])` | | `notIn` | Not in array | `where('status', 'notIn', ['deleted', 'banned'])` | | `between` | Inclusive range | `where('age', 'between', [18, 65])` | Comparison Examples [#comparison-examples] ```typescript // Equal await db.table('users') .where('status', 'eq', 'active') .execute(); // Not equal await db.table('users') .where('status', 'neq', 'deleted') .execute(); // Greater than await db.table('products') .where('price', 'gt', 100) .execute(); // Range await db.table('users') .where('age', 'between', [18, 65]) .execute(); // Pattern matching await db.table('users') .where('email', 'like', '%@gmail.com') .execute(); // Array membership await db.table('orders') .where('status', 'in', ['pending', 'processing', 'shipped']) .execute(); ``` `where('id', 'in', [])` matches nothing. `where('id', 'notIn', [])` excludes nothing, so it behaves like no filter for that condition. Null Checks [#null-checks] ```typescript // Is null await db.table('users') .whereNull('deleted_at') .execute(); // Is not null await db.table('users') .whereNotNull('email') .execute(); ``` You can also express these directly with filter operators: ```typescript await db.table('users') .where('deleted_at', 'isNull', null) .execute(); await db.table('users') .where('email', 'isNotNull', null) .execute(); ``` Tuple IN filters [#tuple-in-filters] Use tuple `IN` when you need membership checks across multiple columns. ```typescript await db.table('orders') .where(['id', 'created_by'], 'inTuple', [[1, 123], [2, 456]]) .execute(); ``` Single-column tuple `IN` is supported too: ```typescript await db.table('users') .where('id', 'inTuple', [[1], [2], [3]]) .execute(); ``` The tuple shape must match the column shape. For example, `where(['id', 'created_by'], 'inTuple', [[1, 2, 3]])` now fails early instead of compiling into a broken query. OR Conditions [#or-conditions] orWhere() [#orwhere] ```typescript const users = await db.table('users') .where('status', 'eq', 'active') .orWhere('status', 'eq', 'pending') .select(['id', 'name', 'status']) .execute(); ``` orWhere with Callback [#orwhere-with-callback] ```typescript const users = await db.table('users') .where('country', 'eq', 'US') .orWhere((expr) => expr.and([ expr.fn('hasAny', 'tags', ['vip', 'premium']), expr.fn('endsWith', 'email', expr.literal('@company.com')), ]) ) .execute(); ``` Function Predicates [#function-predicates] For complex conditions, use predicate builder callbacks: ```typescript const events = await db.table('events') .where((expr) => expr.and([ expr.fn('hasAny', 'tags', ['launch', 'beta']), expr.fn('endsWith', 'status', expr.literal('active')), ]) ) .execute(); ``` Predicate Helpers [#predicate-helpers] | Helper | Purpose | Example | | ------------------------------------------- | ------------------------- | ------------------------------------------- | | `expr.fn(name, ...args)` | Call ClickHouse function | `expr.fn('hasAny', 'tags', ['a', 'b'])` | | `expr.col(column)` | Explicit column reference | `expr.col('created_at')` | | `expr.array(values)` | ClickHouse array literal | `expr.array([1, 2, 3])` | | `expr.literal(value)` / `expr.value(value)` | Force literal value | `expr.literal('active')` | | `expr.raw(sql)` | Inline raw SQL fragment | `expr.raw('date > now() - INTERVAL 1 DAY')` | | `expr.and([...])` | Combine with AND | `expr.and([cond1, cond2])` | | `expr.or([...])` | Combine with OR | `expr.or([cond1, cond2])` | Predicate Examples [#predicate-examples] ```typescript // Using ClickHouse functions await db.table('products') .where((expr) => expr.fn('hasAny', 'categories', ['electronics', 'gadgets']) ) .execute(); // Complex AND/OR await db.table('events') .where((expr) => expr.or([ expr.fn('startsWith', 'event_type', expr.literal('user_')), expr.fn('startsWith', 'event_type', expr.literal('admin_')), ]) ) .execute(); // Combining multiple conditions await db.table('orders') .where((expr) => expr.and([ expr.fn('greater', expr.col('total'), expr.literal(1000)), expr.fn('notEquals', expr.col('status'), expr.literal('cancelled')), ]) ) .execute(); ``` Where Groups [#where-groups] Group conditions with parentheses: ```typescript const users = await db.table('users') .where('status', 'eq', 'active') .whereGroup((builder) => { builder .where('country', 'eq', 'US') .orWhere('country', 'eq', 'CA'); }) .execute(); // Generates: WHERE status = 'active' AND (country = 'US' OR country = 'CA') ``` ```typescript const results = await db.table('orders') .whereGroup((builder) => { builder .where('status', 'eq', 'pending') .orWhere('status', 'eq', 'processing'); }) .whereGroup((builder) => { builder .where('total', 'gte', 100) .orWhere('priority', 'eq', 'high'); }) .execute(); // Generates: WHERE (status = 'pending' OR status = 'processing') // AND (total >= 100 OR priority = 'high') ``` Advanced IN Operators [#advanced-in-operators] ClickHouse supports advanced IN operators for distributed queries, tuples, and subqueries. IN Operators Reference [#in-operators-reference] | Operator | Description | Example | | --------------------------------- | -------------------------------- | --------------------------------------------------- | | `in` / `notIn` | Standard array membership | `where('id', 'in', [1, 2, 3])` | | `globalIn` / `globalNotIn` | GLOBAL IN for distributed tables | `where('user_id', 'globalIn', [1, 2, 3])` | | `inSubquery` / `globalInSubquery` | Subquery string | `where('id', 'inSubquery', 'SELECT id FROM users')` | | `inTable` / `globalInTable` | Table reference | `where('user_id', 'inTable', 'active_users')` | | `inTuple` / `globalInTuple` | Multi-column tuple membership | `where(['c1', 'c2'], 'inTuple', [[1, 2], [3, 4]])` | Standard IN [#standard-in] ```typescript await db.table('users') .where('id', 'in', [1, 2, 3, 4, 5]) .execute(); await db.table('orders') .where('status', 'in', ['pending', 'processing', 'shipped']) .execute(); ``` Tuple IN (Multi-column) [#tuple-in-multi-column] ```typescript await db.table('events') .where(['counter_id', 'user_id'], 'inTuple', [ [34, 123], [101500, 456], ]) .execute(); ``` Subquery IN [#subquery-in] ```typescript await db.table('orders') .where('user_id', 'inSubquery', 'SELECT id FROM users WHERE status = "active"') .execute(); ``` Table Reference IN [#table-reference-in] ```typescript await db.table('events') .where('user_id', 'inTable', 'active_users') .execute(); ``` Global IN (Distributed Tables) [#global-in-distributed-tables] ```typescript await db.table('distributed_events') .where('user_id', 'globalIn', [1, 2, 3]) .execute(); ``` Conditional Where [#conditional-where] Skip where clauses when values are null or undefined: ```typescript function findUsers(filters: { status?: string; minAge?: number }) { return db.table('users') .where(filters.status ? ['status', 'eq', filters.status] : null) .where(filters.minAge ? ['age', 'gte', filters.minAge] : null) .select(['id', 'name', 'email']) .execute(); } // Only applies status filter findUsers({ status: 'active' }); // Only applies age filter findUsers({ minAge: 18 }); // Applies both filters findUsers({ status: 'active', minAge: 18 }); ``` Type Safety [#type-safety] TypeScript ensures operators match column types: ```typescript // ✅ Valid - number column with number comparison await db.table('users') .where('age', 'gte', 18) .execute(); // ✅ Valid - string column with string comparison await db.table('users') .where('status', 'eq', 'active') .execute(); // ✅ Valid - array membership await db.table('users') .where('status', 'in', ['active', 'pending']) .execute(); // ❌ Error - type mismatch (TypeScript may catch this) await db.table('users') .where('age', 'eq', 'not_a_number') .execute(); ``` Examples [#examples] Date Range Filter [#date-range-filter] ```typescript const events = await db.table('events') .where('created_at', 'gte', '2024-01-01') .where('created_at', 'lt', '2024-02-01') .select(['id', 'type', 'created_at']) .execute(); ``` Complex Filter with OR [#complex-filter-with-or] ```typescript const products = await db.table('products') .where('category', 'eq', 'electronics') .orWhere((expr) => expr.and([ expr.fn('hasAny', 'tags', ['featured', 'new']), expr.fn('greater', expr.col('stock'), expr.literal(10)), ]) ) .execute(); ``` Conditional Filtering [#conditional-filtering] ```typescript function searchUsers(query: string, filters: UserFilters) { return db.table('users') .where('name', 'like', \`%\${query}%\`) .where(filters.status ? ['status', 'eq', filters.status] : null) .where(filters.country ? ['country', 'eq', filters.country] : null) .where(filters.minAge ? ['age', 'gte', filters.minAge] : null) .execute(); } ``` Advanced Tuple IN [#advanced-tuple-in] ```typescript const results = await db.table('events') .where(['event_type', 'user_id'], 'inTuple', [ ['page_view', 123], ['page_view', 456], ['click', 123], ]) .execute(); ``` --- # Advanced Patterns (/docs/react/advanced-patterns) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; Advanced Patterns [#advanced-patterns] Learn advanced techniques for configuring and optimizing your React hooks with hypequery. HTTP Method Configuration [#http-method-configuration] By default, `useQuery` and `useMutation` issue `GET` requests. Override HTTP methods per query when you need `POST`, `PUT`, or other verbs:
    ```ts
    import { createHooks } from '@hypequery/react';
    import type { InferApiType } from '@hypequery/serve';
    import type { api } from '@/analytics/queries';

    type Api = InferApiType;

    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api',
      config: {
        weeklyRevenue: { method: 'GET' },    // Read-only queries
        tripStats: { method: 'GET' },
        rebuildMetrics: { method: 'POST' },  // Write operations
        updateMetric: { method: 'PUT' },
      },
    });
    ```
  
When to Use Different Methods [#when-to-use-different-methods] * **GET**: Read-only queries, safe to cache * **POST**: Write operations, mutations, or queries with large inputs * **PUT**: Updates to specific resources * **DELETE**: Removal operations Auto-Config from Server [#auto-config-from-server] Instead of manually maintaining HTTP method configuration, let the server tell the client which methods to use. Option A: Static Manifest (Next.js / Remix / any bundler) [#option-a-static-manifest-nextjs--remix--any-bundler] Generate the route manifest as JSON at build time and import it:
    ```bash
    npx hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json
    ```
  
    ```ts
    // lib/analytics.ts
    import { createHooks } from '@hypequery/react';
    import type { InferApiType } from '@hypequery/serve';
    import type { api } from '@/analytics/api';
    import manifest from '@/analytics/hypequery-manifest.json';

    type Api = InferApiType;

    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api/hypequery',
      manifest, // ✅ Method metadata, no server code in the bundle
    });
    ```
  
The manifest carries the method and full path for every query, metric, and dataset key, so client and server stay in sync without the client importing anything from the server. An earlier version of this page suggested `createHooks({ api })` with a value import of `api`. That only holds if the module is server-only. `lib/analytics.ts` is imported by client components, so a value import of `api` bundles `initServe` and `@hypequery/clickhouse` for the browser — and in Next.js App Router it fails the build with `Can't resolve 'fs/promises'`. Use the static manifest above, or the config endpoint in Option B. Reserve `api` and `api.manifest()` for server-side code. A `manifest` (or an explicit `config` entry) is **required** when you use semantic `metrics`/`datasets` endpoints with `useMetric`/`useDataset`. Their POST paths differ from their map keys, so the hooks need the resolved routes. `extractClientConfig(api)` in Option B includes these routes too. Option B: Config Endpoint (SPAs / Vite) [#option-b-config-endpoint-spas--vite] If your frontend can't import the server module, expose a configuration endpoint: **1. Create a config endpoint:**
    ```ts
    // app/api/hypequery-config/route.ts
    import { extractClientConfig } from '@hypequery/serve';
    import { api } from '@/analytics/queries';

    export function GET() {
      return Response.json(extractClientConfig(api));
    }
    ```
  
**2. Load config at runtime:**
    ```ts
    // lib/analytics.ts
    import { createHooks } from '@hypequery/react';
    import type { InferApiType } from '@hypequery/serve';
    import type { api } from '@/analytics/queries';

    type Api = InferApiType;

    let hooksPromise: Promise>> | null = null;

    export function getHypequeryHooks() {
      if (!hooksPromise) {
        hooksPromise = fetch('/api/hypequery-config')
          .then((res) => res.json())
          .then((config) =>
            createHooks({ baseUrl: '/api/hypequery', config })
          );
      }
      return hooksPromise;
    }
    ```
  
**3. Initialize in your app:**
    ```tsx
    // app/providers.tsx
    import { getHypequeryHooks } from '@/lib/analytics';
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
    import { useEffect, useState } from 'react';

    const queryClient = new QueryClient();

    export function AppProviders({ children }: { children: React.ReactNode }) {
      const [hooks, setHooks] = useState(null);

      useEffect(() => {
        getHypequeryHooks().then(setHooks);
      }, []);

      if (!hooks) return 
Loading...
; return ( {children} ); } ```
Query Client Access [#query-client-access] Access the TanStack Query client directly for advanced cache management:
    ```tsx
    import { useQueryClient } from '@tanstack/react-query';

    function MetricsPanel() {
      const queryClient = useQueryClient();
      const { data } = useQuery('metrics', {});

      const handleRefresh = () => {
        // Invalidate specific queries
        queryClient.invalidateQueries({ queryKey: ['metrics'] });

        // Or clear entire cache
        queryClient.clear();
      };

      return (
        
{data?.total}
); } ```
Cache Invalidation [#cache-invalidation] Invalidate After Mutations [#invalidate-after-mutations] Automatically refresh related queries when data changes:
    ```tsx
    import { useMutation, useQuery } from '@/lib/analytics';
    import { useQueryClient } from '@tanstack/react-query';

    function MetricsManager() {
      const queryClient = useQueryClient();
      const { data: metrics } = useQuery('metrics', {});

      const rebuild = useMutation('rebuildMetrics', {
        onSuccess: () => {
          // Invalidate related queries
          queryClient.invalidateQueries({ queryKey: ['metrics'] });
          queryClient.invalidateQueries({ queryKey: ['weeklyRevenue'] });
        },
      });

      return (
        
      );
    }
    ```
  
Prefetching [#prefetching] Preload data before it's needed:
    ```tsx
    import { useQueryClient } from '@tanstack/react-query';

    function Dashboard() {
      const queryClient = useQueryClient();

      useEffect(() => {
        // Prefetch on mount
        queryClient.prefetchQuery({
          queryKey: ['weeklyRevenue', { startDate: '2025-01-01' }],
          queryFn: () => fetch('/api/weeklyRevenue?startDate=2025-01-01')
            .then(res => res.json()),
        });
      }, []);

      // ...
    }
    ```
  
Query Keys [#query-keys] hypequery hooks use a fixed query-key structure: `['hypequery', name]`, or `['hypequery', name, input]` when the query has input. Dataset hooks key on their resolved name (`dataset:`). Use these keys with TanStack's cache APIs:
    ```tsx
    // Invalidate one query (must match name + input).
    queryClient.invalidateQueries({ queryKey: ['hypequery', 'weeklyRevenue'] });

    // Invalidate every hypequery cache entry by prefix.
    queryClient.invalidateQueries({ queryKey: ['hypequery'] });
    ```
  
The key structure is not configurable — relying on the stable `'hypequery'` prefix lets you target hypequery caches without colliding with other TanStack queries. Auth headers and token refresh [#auth-headers-and-token-refresh] Add auth tokens with the `headers` option. It accepts a static object or a (optionally async) function that runs per request, so it can supply a fresh short-lived token. Pair it with `onUnauthorized` to refresh credentials and retry once on a `401`:
    ```tsx
    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api',
      headers: async () => ({
        Authorization: `Bearer ${await getAuthToken()}`,
      }),
      onUnauthorized: async () => {
        // Runs on a 401; refresh the token, then the request retries once.
        await refreshSession();
      },
    });
    ```
  
If you need full control over the request, override the fetch implementation with `fetchFn`:
    ```tsx
    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api',
      fetchFn: async (url, options) => {
        const token = getAuthToken();

        return fetch(url, {
          ...options,
          headers: {
            ...options?.headers,
            Authorization: `Bearer ${token}`,
          },
        });
      },
    });
    ```
  
Suspense Mode [#suspense-mode] TanStack Query v5 removed the per-call `suspense: true` flag in favor of dedicated `useSuspenseQuery`/`useSuspenseInfiniteQuery` hooks. hypequery's hooks wrap the non-suspense hooks, so handle loading with `isLoading` (shown elsewhere on this page). If you need Suspense, read the endpoint with your own `useSuspenseQuery` call against the same route. Use React Suspense for declarative loading states:
    ```tsx
    import { Suspense } from 'react';
    import { useSuspenseQuery } from '@tanstack/react-query';

    function MetricsChart() {
      // Throws a promise while loading, so the parent  shows its fallback.
      const { data } = useSuspenseQuery({
        queryKey: ['hypequery', 'weeklyRevenue', { startDate: '2025-01-01' }],
        queryFn: () =>
          fetch('/api/weeklyRevenue?startDate=2025-01-01').then((res) => res.json()),
      });

      return 
Total: ${data.total}
; } function Dashboard() { return ( Loading metrics...}> ); } ```
Error Boundaries [#error-boundaries] Handle errors declaratively with error boundaries:
    ```tsx
    import { ErrorBoundary } from 'react-error-boundary';

    function Dashboard() {
      return (
        Failed to load metrics}
          onError={(error) => console.error('Metrics error:', error)}
        >
          
        
      );
    }
    ```
  
Advanced TanStack Query Options [#advanced-tanstack-query-options] All TanStack Query options are supported:
    ```tsx
    const { data, refetch, isFetching } = useQuery(
      'weeklyRevenue',
      { startDate: '2025-01-01' },
      {
        // Caching
        staleTime: 5 * 60 * 1000, // 5 minutes
        gcTime: 10 * 60 * 1000, // 10 minutes (was `cacheTime` before TanStack v5)

        // Refetching
        refetchOnMount: true,
        refetchOnWindowFocus: false,
        refetchInterval: 30000, // Poll every 30s

        // Conditional fetching
        enabled: isAuthenticated,

        // Retries
        retry: 3,
        retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      }
    );
    ```
  
Per-query `onSuccess`/`onError` callbacks were removed from `useQuery` in TanStack Query v5. Use `useMutation` callbacks for writes, or subscribe via the global `QueryCache` if you need query-level side effects. Background Refetching [#background-refetching] Keep data fresh with background updates:
    ```tsx
    function LiveMetrics() {
      const { data, dataUpdatedAt } = useQuery(
        'liveMetrics',
        {},
        {
          staleTime: 0, // Always consider stale
          refetchInterval: 5000, // Refetch every 5 seconds
          refetchIntervalInBackground: true, // Continue even when tab is hidden
        }
      );

      return (
        
Active Users: {data?.activeUsers}
Updated: {new Date(dataUpdatedAt).toLocaleTimeString()}
); } ```
Parallel Queries [#parallel-queries] Execute multiple queries efficiently:
    ```tsx
    function Dashboard() {
      const revenue = useQuery('weeklyRevenue', { startDate: '2025-01-01' });
      const users = useQuery('activeUsers', { limit: 100 });
      const metrics = useQuery('systemMetrics', {});

      // All three queries run in parallel
      if (revenue.isLoading || users.isLoading || metrics.isLoading) {
        return 
Loading...
; } return (
); } ```
Infinite Queries [#infinite-queries] hypequery hooks include offset-paginated `useInfiniteQuery`, `useInfiniteMetric`, and `useInfiniteDataset`. You don't wire up `getNextPageParam` yourself — the hooks advance pages using the `meta.pagination` the server returns (requested automatically via the `x-include-meta` header). Set `limit` as the page size; `offset`, if provided, is the starting offset.
    ```tsx
    import { useInfiniteDataset } from '@/lib/analytics';

    function InfiniteOrders() {
      const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
      } = useInfiniteDataset('orders', {
        dimensions: ['country', 'status'],
        measures: ['revenue'],
        limit: 20, // page size
      });

      return (
        
{data?.pages.map((page, i) => (
{/* Each page is the endpoint response: `{ data, meta }`. */} {page.data.map((row, j) => (
{row.country}: {row.revenue}
))}
))} {hasNextPage && ( )}
); } ```
`useInfiniteMetric` works the same way for a named metric, and `useInfiniteQuery` for a plain query whose endpoint returns `meta.pagination`. --- # Getting Started (/docs/react/getting-started) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; React Hooks - Getting Started [#react-hooks---getting-started] Use `@hypequery/react` to generate type-safe hooks (`useQuery`, `useMutation`) backed by TanStack Query. Bring your exported `serve({ queries })` API type into your React app without duplicating schemas. For semantic `metrics` and `datasets` endpoints, use `createAnalyticsHooks`, which adds `useMetric` and `useDataset` on top of the base hooks. See [Analytics hooks for metrics and datasets](#analytics-hooks-for-metrics-and-datasets) below. Installation [#installation]
    ```bash
    npm install @hypequery/react @tanstack/react-query
    ```
  
Peer dependencies: `react@^18`, `@tanstack/react-query@^5`. Setup [#setup] Option 1: Automatic Type Inference (Recommended) [#option-1-automatic-type-inference-recommended] Use `InferApiType` to automatically extract types from your API definition:
    ```ts
    // lib/analytics.ts
    import { createHooks } from '@hypequery/react';
    import type { InferApiType } from '@hypequery/serve';
    import type { api } from '@/analytics/api';

    // Automatic type inference - no manual type definition needed!
    type Api = InferApiType;

    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api', // where your hypequery routes live
    });
    ```
  
This eliminates the need to manually define and maintain a separate type for your API. Both `@hypequery/serve` imports here are `import type`, so they are erased at build time and no server code reaches the client bundle. Keep it that way — this module is imported by client components. Option 2: Manual Type Definition [#option-2-manual-type-definition] If you prefer to manually define your API types:
    ```ts
    // lib/analytics.ts
    import { createHooks } from '@hypequery/react';

    type Api = {
      weeklyRevenue: {
        input: { startDate: string };
        output: { total: number };
      };
      // ... other queries
    };

    export const { useQuery, useMutation } = createHooks({
      baseUrl: '/api',
    });
    ```
  
Analytics hooks for metrics and datasets [#analytics-hooks-for-metrics-and-datasets] When your API registers `metrics` or `datasets`, use `createAnalyticsHooks` instead of `createHooks`. It returns everything `createHooks` does, plus `useMetric`, `useDataset`, `useInfiniteMetric`, and `useInfiniteDataset`. Semantic endpoints are POST routes whose paths differ from their map keys (e.g. the `orders` dataset lives at `POST /datasets/orders/query`), so the hooks need a `manifest` to resolve them. Generate the manifest as static JSON and import **the type** of your API, never its value:
    ```bash
    npx hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json
    ```
  
    ```ts
    // lib/analytics.ts
    import { createAnalyticsHooks } from '@hypequery/react';
    import type { InferApiType } from '@hypequery/serve';
    import type { api } from '@/analytics/api';
    import manifest from '@/analytics/hypequery-manifest.json';

    // `import type` is erased at build time, so the server module — and the
    // ClickHouse client it constructs — never reaches the browser bundle.
    type Api = InferApiType;

    export const {
      useQuery,
      useMutation,
      useMetric,
      useDataset,
      useInfiniteMetric,
      useInfiniteDataset,
    } = createAnalyticsHooks({
      baseUrl: '/api',
      manifest,
    });
    ```
  
Re-run `generate:manifest` whenever you add or re-route an endpoint. Wire it into your build (`"prebuild": "hypequery generate:manifest …"`) so it cannot drift. This module is imported by client components, so anything it imports as a *value* is bundled for the browser. Calling `manifest: api.manifest()` requires a value import of `api`, which pulls `initServe` and `@hypequery/clickhouse` into the client graph. In a Next.js App Router project that fails the build outright:
      ```text
      ./node_modules/@hypequery/clickhouse/dist/cli/generate-types.js:2:1
      Error: Module not found: Can't resolve 'fs/promises'
      ```
    
`api.manifest()` is a **server-side** API. Use it in your build step, in a config endpoint, or anywhere else that only runs on the server — not in the module that exports your hooks.
`api.manifest()` output is plain JSON, so you can also serve it from a small endpoint and fetch it at runtime when a static file does not suit your setup — see Auto-config from server. Without a manifest (or an explicit config entry), `useMetric`/`useDataset` throw a clear error rather than calling the wrong URL. Provider Setup [#provider-setup] Wrap your app with TanStack Query's provider:
    ```tsx
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

    const queryClient = new QueryClient();

    export function AppProviders({ children }: { children: React.ReactNode }) {
      return (
        
          {children}
        
      );
    }
    ```
  
    ```tsx
    // app.tsx or _app.tsx
    import { AppProviders } from './providers';

    function App() {
      return (
        
          
        
      );
    }
    ```
  
--- # Using Queries (/docs/react/using-queries) import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; Using Queries [#using-queries] Once you've set up `@hypequery/react`, you can use `useQuery` and `useMutation` in your components with full type safety. useQuery [#usequery] Fetch data from your hypequery API with automatic caching and refetching:
    ```tsx
    import { useQuery } from '@/lib/analytics';

    function RevenueChart() {
      const { data, error, isLoading } = useQuery('weeklyRevenue', {
        startDate: '2025-01-01',
      });

      if (isLoading) return 
Loading...
; if (error) return
Error: {error.message}
; return
Total: ${data.total}
; } ```
Type Safety [#type-safety] The query name and input are fully typed:
    ```tsx
    // ✅ Type-safe
    const { data } = useQuery('weeklyRevenue', {
      startDate: '2025-01-01',
    });

    // ❌ TypeScript error - invalid query name
    const { data } = useQuery('invalidQuery', { ... });

    // ❌ TypeScript error - invalid input
    const { data } = useQuery('weeklyRevenue', {
      invalidField: 'value',
    });
    ```
  
TanStack Query Options [#tanstack-query-options] All TanStack Query options are supported:
    ```tsx
    const { data } = useQuery('weeklyRevenue',
      { startDate: '2025-01-01' },
      {
        staleTime: 5 * 60 * 1000, // 5 minutes
        refetchOnWindowFocus: false,
        enabled: isAuthenticated, // Conditional fetching
      }
    );
    ```
  
useMutation [#usemutation] Execute write operations or actions:
    ```tsx
    import { useMutation } from '@/lib/analytics';

    function RebuildButton() {
      const rebuild = useMutation('rebuildMetrics');

      return (
        
      );
    }
    ```
  
Handling Success and Errors [#handling-success-and-errors]
    ```tsx
    const rebuild = useMutation('rebuildMetrics', {
      onSuccess: (data) => {
        console.log('Rebuild complete:', data);
        // Invalidate related queries
        queryClient.invalidateQueries({ queryKey: ['weeklyRevenue'] });
      },
      onError: (error) => {
        console.error('Rebuild failed:', error);
      },
    });
    ```
  
Optimistic Updates [#optimistic-updates]
    ```tsx
    const updateMetric = useMutation('updateMetric', {
      onMutate: async (newData) => {
        // Cancel outgoing refetches
        await queryClient.cancelQueries({ queryKey: ['metrics'] });

        // Snapshot current value
        const previous = queryClient.getQueryData(['metrics']);

        // Optimistically update
        queryClient.setQueryData(['metrics'], (old) => ({
          ...old,
          ...newData,
        }));

        return { previous };
      },
      onError: (err, variables, context) => {
        // Rollback on error
        queryClient.setQueryData(['metrics'], context.previous);
      },
      onSettled: () => {
        // Refetch after success or error
        queryClient.invalidateQueries({ queryKey: ['metrics'] });
      },
    });
    ```
  
useMetric and useDataset [#usemetric-and-usedataset] When you set up hooks with `createAnalyticsHooks`, use `useMetric` and `useDataset` for semantic endpoints. They take the same TanStack options as `useQuery`, and the input is the metric or dataset query (dimensions, measures, filters, ordering, limits). `useMetric` runs a named metric. Reference it by metric name:
    ```tsx
    import { useMetric } from '@/lib/analytics';

    function RevenueByCountry() {
      const { data, isLoading } = useMetric('revenue', {
        dimensions: ['country'],
        filters: [{ field: 'status', operator: 'eq', value: 'completed' }],
        orderBy: [{ field: 'revenue', direction: 'desc' }],
        limit: 10,
      });

      if (isLoading) return 
Loading...
; // Semantic endpoints return `{ data, meta }`. return (
    {data?.data.map((row) => (
  • {row.country}: {row.revenue}
  • ))}
); } ```
`useDataset` runs an ad hoc dataset query. Reference it by dataset name — the hook maps it to the `dataset:` route for you:
    ```tsx
    import { useDataset } from '@/lib/analytics';

    function OrdersRollup() {
      const { data, isLoading } = useDataset('orders', {
        dimensions: ['country', 'status'],
        measures: ['revenue', 'orderCount'],
        filters: [{ field: 'status', operator: 'eq', value: 'completed' }],
        limit: 25,
      });

      if (isLoading) return 
Loading...
; return ( {data?.data.map((row, i) => ( ))}
{row.country} {row.status} {row.revenue} {row.orderCount}
); } ```
Both hooks validate against the server-side contract: an invalid dimension, measure, or filter returns a `400` error you can handle the same way as any other query. For cursor-free pagination, see [`useInfiniteDataset`](/docs/react/advanced-patterns#infinite-queries). Error Handling [#error-handling] Errors from your hypequery API are structured:
    ```tsx
    const { data, error } = useQuery('weeklyRevenue', { startDate: '2025-01-01' });

    if (error) {
      // Validation errors from hypequery
      if (error.status === 400) {
        return 
Invalid input: {error.message}
; } // Network errors if (error.name === 'TypeError') { return
Network error. Please check your connection.
; } // Generic error return
Something went wrong: {error.message}
; } ```
Common Patterns [#common-patterns] Dependent Queries [#dependent-queries] Execute queries in sequence:
    ```tsx
    function UserRevenue({ userId }: { userId: string }) {
      const { data: user } = useQuery('getUser', { userId });

      const { data: revenue } = useQuery(
        'userRevenue',
        { userId },
        { enabled: !!user } // Only run after user is loaded
      );

      return 
{revenue?.total}
; } ```
Polling [#polling] Automatically refetch data at intervals:
    ```tsx
    const { data } = useQuery(
      'liveMetrics',
      {},
      { refetchInterval: 5000 } // Poll every 5 seconds
    );
    ```
  
Manual Refetch [#manual-refetch] Trigger refetch on demand:
    ```tsx
    function MetricsPanel() {
      const { data, refetch } = useQuery('metrics', {});

      return (
        
{data?.total}
); } ```
--- # ClickHouse Behavior (/docs/reference/clickhouse-behavior) hypequery is built for ClickHouse, so some of the behavior you see is really ClickHouse behavior showing through. This page covers the main things worth knowing so the API feels less surprising. What hypequery exposes directly [#what-hypequery-exposes-directly] These are the ClickHouse features that show up clearly in the builder: | Feature | Notes | | ---------------------------------- | --------------------------------------------------------------------- | | `PREWHERE` | Separate from `WHERE`, with `prewhere(...)` and `orPrewhere(...)` | | `FINAL` | Exposed directly as `.final()` because it is a table-read concern | | Query `SETTINGS` | Passed through to ClickHouse for that execution | | `GLOBAL IN` / `GLOBAL NOT IN` | Exposed through `globalIn` and `globalNotIn` operators | | `IN` subqueries | Exposed through `inSubquery` and `globalInSubquery` | | `IN` table references | Exposed through `inTable` and `globalInTable` | | Tuple membership | Exposed through `inTuple` and `globalInTuple` | | `WITH` clause CTEs | Exposed through `withCTE(...)` | | `WITH` scalar expressions | Exposed through `withScalar(...)` | | `ARRAY JOIN` / `LEFT ARRAY JOIN` | Exposed through `arrayJoin(...)` and `leftArrayJoin(...)` | | `LIMIT ... BY ...` | Exposed through `limitBy(...)` | | `GROUP BY ... WITH TOTALS` | Exposed through `withTotals()` | | ClickHouse time bucketing | Exposed through `groupByTimeInterval(...)` and related helpers | | Explicit null helpers | Prefer `whereNull(...)` and related helpers over bare `null` equality | | `LEFT JOIN` null semantics control | Use `.settings({ join_use_nulls: 1 })` when SQL-style nulls matter | The point is not to hide ClickHouse behind a generic SQL layer. These features are exposed because they matter in real queries. PREWHERE is different from WHERE [#prewhere-is-different-from-where] `PREWHERE` is its own ClickHouse clause. It is not just another spelling of `WHERE`. Use: * `prewhere(...)` for highly selective predicates that should run earlier * `where(...)` for the rest of your filtering logic hypequery keeps them separate and does not try to rewrite one into the other for you. FINAL belongs to the table read [#final-belongs-to-the-table-read] `FINAL` is about how ClickHouse reads a table. It is not a general-purpose query modifier. In hypequery you can use: * `.final()` for simple single-table reads * source-level `FINAL` behavior when the query shape gets more advanced Use it when merge-time correctness matters for the table you are reading. Array joins are their own ClickHouse feature [#array-joins-are-their-own-clickhouse-feature] `ARRAY JOIN` and `LEFT ARRAY JOIN` are not ordinary relational joins. Use: * `arrayJoin(...)` when array elements should expand into separate rows * `leftArrayJoin(...)` when you want that expansion but still need rows with empty arrays preserved These map directly to ClickHouse array-join behavior. LIMIT BY is different from LIMIT [#limit-by-is-different-from-limit] `LIMIT BY` limits rows per grouping key. It does not just cap the final result set. Use: * `limitBy(count, by)` when you want a per-group cap * `limit(count)` when you want a cap on the final result You can use both in the same query when needed. Query settings pass through to ClickHouse [#query-settings-pass-through-to-clickhouse] `.settings({...})` becomes ClickHouse query settings for that execution. This is the right place for settings like: * `max_execution_time` * `join_use_nulls` * `max_threads` * `final` The SQL text and the settings payload stay separate. Settings affect execution, not the SQL string itself. LEFT JOIN defaults are a ClickHouse behavior [#left-join-defaults-are-a-clickhouse-behavior] ClickHouse does not default unmatched `LEFT JOIN` columns to `null` unless `join_use_nulls = 1` is enabled. That means the right-hand side of a `LEFT JOIN` may come back with type defaults instead of `null` unless you opt into null semantics. If you need SQL-style null behavior, set: * `.settings({ join_use_nulls: 1 })` That is a ClickHouse rule, not a hypequery quirk. Null handling is explicit [#null-handling-is-explicit] Plain `null` equality is not a great fit for how ClickHouse works. Prefer: * `whereNull(...)` * `whereNotNull(...)` * `prewhereNull(...)` * `prewhereNotNull(...)` These helpers make the intent obvious and avoid pretending ClickHouse behaves like Postgres. Runtime values come from JSONEachRow [#runtime-values-come-from-jsoneachrow] The values you get back should match what the ClickHouse JavaScript client returns from `JSONEachRow`. That means some values may not come back in the JavaScript shape people expect at first glance. Common examples: | ClickHouse value | Typical runtime value | | --------------------------------------------- | --------------------- | | `UInt64` / `Int64` | `string` | | `count()` and other 64-bit integer aggregates | `string` | | `DateTime` / `DateTime64` | `string` | | `Decimal(p, s)` | `number` | One important caveat: JavaScript numbers can lose precision for larger decimal values even when ClickHouse stored them exactly. 64-bit integer encoding and strict read-only users [#64-bit-integer-encoding-and-strict-read-only-users] The built-in adapter requests quoted JSON values for `Int64` and wider integer types. This prevents precision loss beyond JavaScript's safe integer range and keeps runtime values aligned with the generated `string` types. A ClickHouse user with `readonly = 1` can reject that request when its effective profile does not already enable quoted integers. For such a connection, opt into the profile's existing behavior explicitly: ```ts const db = createQueryBuilder({ url: process.env.CLICKHOUSE_URL!, username: process.env.CLICKHOUSE_USERNAME!, password: process.env.CLICKHOUSE_PASSWORD ?? '', integerJsonEncoding: 'server-default', }); ``` `server-default` omits the adapter-owned `output_format_json_quote_64bit_integers` setting rather than sending `0`. It does not probe the server or retry the query, and explicit `clickhouse_settings` are still passed through unchanged. The tradeoff is that the server may return wide integers as JavaScript numbers. Values beyond `2^53` can then lose precision and may not match the generated `string` types. When possible, configuring the read-only user's ClickHouse profile to quote wide integers preserves the default precision-safe behavior. Practical guidance [#practical-guidance] If you are deciding where something belongs, the usual rules are: * use `prewhere(...)` for highly selective early filters * use `where(...)` for the rest of your filtering logic * use `final()` when merge-time correctness matters for the table read * use `.settings({...})` for ClickHouse runtime behavior like `join_use_nulls` or `max_threads` * use explicit null helpers when you mean null checks --- # Connecting to ClickHouse (/docs/reference/connection) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Callout } from 'fumadocs-ui/components/callout'; Connecting to ClickHouse [#connecting-to-clickhouse] hypequery provides a type-safe way to connect to ClickHouse from Node.js and browser-capable runtimes. Run `npx hypequery init` to scaffold your ClickHouse connection, env variables, and schema generation so you can skip the manual setup steps below. Use `url` in new code and `CLICKHOUSE_URL` in new env files. `host` is still supported for backward compatibility, but it is deprecated. Connection Setup [#connection-setup] For Node.js environments, hypequery can use the Node.js ClickHouse client automatically: ```typescript import { createQueryBuilder } from '@hypequery/clickhouse'; const db = createQueryBuilder({ url: 'http://localhost:8123', username: 'default', password: 'password', database: 'my_database' }); ``` **Client Selection:** * In Node.js, hypequery can use `@clickhouse/client` automatically * In browser or universal setups, pass an explicit `@clickhouse/client-web` client **Requirements:** * Node.js usage requires `@clickhouse/client` * Browser or universal usage requires `@clickhouse/client-web` and explicit client injection Browser / universal setup [#browser--universal-setup] ```typescript import { createQueryBuilder } from '@hypequery/clickhouse'; import { createClient } from '@clickhouse/client-web'; const client = createClient({ url: 'https://your-clickhouse-host', username: 'default', password: '', database: 'my_database' }); const db = createQueryBuilder({ client, }); ``` Connecting an end-user browser directly to ClickHouse is not recommended. Any credentials shipped to the browser — including values in `NEXT_PUBLIC_*` or `VITE_*` variables — are public, so anyone can run whatever that ClickHouse user is allowed to. If you do it, restrict the user (read-only, quotas, row policies); otherwise keep credentials in server-only variables and have browsers call an authenticated server API such as one built with `@hypequery/serve`. Embedded ClickHouse (chDB) [#embedded-clickhouse-chdb] You don't need a ClickHouse server at all: [chDB](https://github.com/chdb-io/chdb) runs the ClickHouse engine inside your Node process, and the chDB team maintains a hypequery adapter (`chdb/hypequery`). Same builder code, no server — ideal for local development, CI tests, and serverless: ```typescript import { createQueryBuilder } from '@hypequery/clickhouse'; import { Session } from 'chdb'; import { chdbAdapter } from 'chdb/hypequery'; const session = new Session('./analytics.chdb'); // or new Session() for in-memory const db = createQueryBuilder({ adapter: chdbAdapter({ session }) }); ``` Swap to remote ClickHouse for production by handing `createQueryBuilder` your connection details instead of the adapter — nothing else changes. See [chDB (Embedded ClickHouse)](/docs/chdb) for the full guide. More generally, `createQueryBuilder({ adapter })` accepts any implementation of the `DatabaseAdapter` contract, so the builder can run against any engine that speaks ClickHouse SQL. Connection Options [#connection-options] hypequery supports all connection options provided by the official ClickHouse JavaScript client with full type safety: Core Connection Options [#core-connection-options] | Option | Type | Description | Default | | ---------- | -------- | ---------------------------------------------------------------------- | --------- | | `url` | `string` | The URL of the ClickHouse server, including protocol and port | Required | | `host` | `string` | Deprecated alias for `url`, still supported for backward compatibility | Optional | | `username` | `string` | Username for authentication | 'default' | | `password` | `string` | Password for authentication | undefined | | `database` | `string` | The database to connect to | 'default' | For env vars, prefer `CLICKHOUSE_URL`. Older `CLICKHOUSE_HOST` setups can continue to work while you migrate. Advanced Options [#advanced-options] | Option | Type | Description | | --------------------- | ------------------------------------------- | ------------------------------------------------------ | | `http_headers` | `Record` | Custom HTTP headers to include with each request | | `request_timeout` | `number` | Request timeout in milliseconds | | `compression` | `{ response?: boolean; request?: boolean }` | Enable compression for requests and/or responses | | `application` | `string` | Application name to identify in ClickHouse server logs | | `keep_alive` | `{ enabled: boolean }` | Keep-alive connection settings | | `log` | `any` | Logger configuration | | `clickhouse_settings` | `ClickHouseSettings` | Additional ClickHouse-specific settings | References and Resources [#references-and-resources] hypequery's connection options are fully compatible with the official ClickHouse JavaScript client. Our connection implementation provides enhanced type safety and intelligent client selection while maintaining full compatibility. For additional details on ClickHouse connection options, refer to: * [ClickHouse JavaScript Client Documentation](https://clickhouse.com/docs/integrations/javascript) * [ClickHouse Connection Settings](https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings) --- # Inside the Query Builder (/docs/reference/inside-the-query-builder) import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Pre } from 'fumadocs-ui/components/codeblock'; This page is a short map of how the builder works internally. It is most useful if you are debugging query generation, contributing to the builder, or trying to understand how the internal pieces fit together. The basic idea [#the-basic-idea] The builder is ClickHouse-first and type-driven. Internally, it builds a structured query tree instead of mutating a SQL string. At a high level, the flow is: 1. builder methods update a typed query node 2. the builder preserves type state alongside that query node 3. the dialect compiles the query node into SQL plus parameters 4. the adapter executes the compiled query against ClickHouse Each layer has a clear job: * the builder creates the typed query shape * the dialect turns that shape into SQL * the adapter sends the query to ClickHouse The source of truth [#the-source-of-truth] The main internal representation is a root `select-query` node. It stores structured versions of things like: * `from` * `select` * `prewhere` * `where` * `joins` * `groupBy` * `having` * `orderBy` * `ctes` * `settings` For public inspection, prefer `getQueryNode()` when you want a snapshot of the current structured query. `getConfig()` still exists for compatibility, but it is now a deprecated legacy inspection method. Why the query tree matters [#why-the-query-tree-matters] The query tree makes a few things much easier: * it lets builders branch without mutating each other * it keeps `PREWHERE` and `WHERE` separate * it preserves things like `FINAL`, joins, and `HAVING` * it gives the SQL compiler a clean structured input Immutability and branching [#immutability-and-branching] Builder instances are intended to be immutable from the caller's point of view. That means code like this should behave predictably:
    ```typescript
    const base = db.table('events').select(['id', 'user_id']);

    const recent = base.orderBy('id', 'DESC').limit(10);
    const active = base.where('is_active', 'eq', 1);
    ```
  
`recent` and `active` should both come from `base` without changing `base` or each other. That is why the builder creates new query state instead of writing into one shared object. Expressions and filtering [#expressions-and-filtering] Filtering is stored as structured expression nodes rather than loose condition strings. That helps the builder preserve: * `AND` and `OR` sequences * nested groups * raw predicate expressions * separate `PREWHERE` and `WHERE` trees * explicit null checks like `IS NULL` and `IS NOT NULL` Useful mental model [#useful-mental-model] If you are reading or changing the builder, this is the simplest mental model: * the query node is the canonical internal shape * builder state still carries type information about tables, aliases, scalars, and output shape * features like filtering, joins, aggregations, and modifiers update that structured query model * the dialect is where the final SQL string and ordered parameter list are produced * the adapter is where execution concerns such as ClickHouse settings, query ids, rendering, and transport live Dialect boundary [#dialect-boundary] The ClickHouse dialect takes the structured query node and produces: * compiled SQL * ordered parameters This is also where ClickHouse-specific SQL rendering details live, such as: * how expression trees become SQL text * how `PREWHERE` is emitted * how table sources like `FINAL` are rendered * how `HAVING`, `CTEs`, and ordering are serialized Adapter boundary [#adapter-boundary] After compilation, the adapter handles execution concerns like: * passing query settings through to the client * parameterized execution * rendered SQL for debugging and logs * cache namespacing and query execution metadata Where ClickHouse behavior lives [#where-clickhouse-behavior-lives] The builder internals and ClickHouse behavior are related, but they are not the same thing. * this page explains the builder's internal model and compilation flow * [ClickHouse Behavior](/docs/reference/clickhouse-behavior) explains the ClickHouse-specific behavior that shapes the public API If you are deciding whether a change belongs to the builder model or to ClickHouse behavior, that is the line to keep in mind. Practical guidance for contributors [#practical-guidance-for-contributors] If you are changing the builder internals, the usual rules are: * preserve immutability when branching builders * keep ClickHouse-specific semantics explicit rather than hiding them behind generic SQL assumptions * prefer extending the structured query model over smuggling more meaning through loose strings * keep type-state changes aligned with query-shape changes * add tests at the query-node, SQL, and behavior level when a change is subtle --- # Package Overview (/docs/reference/packages) Package Overview [#package-overview] hypequery ships a small set of focused packages. Use this page as a quick reference for which package solves which problem. | Package | Purpose | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@hypequery/serve` | Core runtime for authoring queries with `initServe`, registering semantic `metrics`/`datasets`, exposing them via HTTP, and executing them in-process (cron, SSR, agents). Includes middleware, auth, OpenAPI/docs, and adapters. | | `@hypequery/clickhouse` | Fluent, type-safe ClickHouse client used inside `ctx.db`. Exposes the query builder, expression helpers, caching utilities, and logging APIs you can also use directly on `db`. | | `@hypequery/datasets` | Type-safe semantic layer. Define datasets, dimensions, measures, and metrics once with `dataset()`/`createDatasetClient()`, then reuse them across the query builder, Serve endpoints, MCP, and jobs. | | `@hypequery/cli` | Developer tooling (`hypequery init`, `hypequery dev`, `hypequery generate`). Handles scaffolding, schema introspection, and local docs. | | `@hypequery/react` | TanStack Query-powered hooks (`useQuery`, `useMutation`, plus `useMetric`/`useDataset` via `createAnalyticsHooks`) generated from your exported `api` so React apps can call queries, metrics, and datasets with full typing. | | `@hypequery/mcp` | Model Context Protocol server that exposes your datasets and metrics to AI agents through governed tools, without handing out raw SQL access. | Typical project setup [#typical-project-setup] Most apps depend on just three packages: 1. `@hypequery/serve` – define and expose metrics 2. `@hypequery/clickhouse` – run ClickHouse queries within those metrics 3. `@hypequery/cli` – drive the local dev server + schema/codegen tasks Reach for `@hypequery/react` when you need hooks in UI code. --- # Trust Boundaries (/docs/reference/trust-boundaries) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; hypequery has two different API surfaces: * **Semantic query APIs** accept constrained dataset and metric requests from users, dashboards, and agents. * **Query builder and raw SQL APIs** execute trusted developer-authored SQL. Keep those surfaces separate. Semantic endpoints are the public input boundary. Raw SQL helpers are for application code you control. Public semantic input [#public-semantic-input] Dataset and metric requests are designed for public-facing runtime input: ```typescript await analytics.execute(Orders, { dimensions: ['status'], measures: ['revenue'], filters: [{ field: 'status', operator: 'eq', value: 'completed' }], limit: 100, }); ``` Callers choose from declared semantic fields. Validation rejects unknown dimensions, measures, filters, order fields, time grains, and limits before SQL is generated. This is the surface Serve, OpenAPI schemas, MCP tools, and generated dataset tools should expose. Trusted developer code [#trusted-developer-code] The ClickHouse query builder is a developer API. It intentionally exposes escape hatches for ClickHouse features that are not modeled by the fluent builder: ```typescript await db.rawQuery( `SELECT count() AS signups FROM signups WHERE account_id = ?`, [accountId], ); db.table('events') .select([rawAs('JSONExtractString(metadata, \'country\')', 'country')]); ``` Raw SQL fragments, raw queries, table names, aliases, and custom expressions are not a safe public query language. Do not assemble those strings from user or agent input. Use raw SQL only in code you own. If an end user or agent needs analytics access, expose a semantic dataset, metric, Serve endpoint, MCP tool, or generated tool schema instead. SQL-backed semantic fields [#sql-backed-semantic-fields] Datasets also support SQL-backed dimensions and measures: ```typescript dimensions: { countryUpper: dimension.string({ sql: 'upper(country_code)' }), }, measures: { taxedRevenue: measure.sum('amount', { sql: 'amount * 1.2' }), } ``` These expressions are part of the semantic model and should be authored by trusted application code. Runtime callers can reference `countryUpper` or `taxedRevenue`, but they should not provide the SQL expression itself. Schema compatibility checks can validate simple column references more deeply than complex SQL expressions. Treat SQL-backed fields as reviewed model code. Agent access [#agent-access] Agents should receive semantic tools, not generic SQL execution: * expose dataset and metric names through a catalog * constrain field names and operators with JSON Schema enums * hide tenant selection from tool input * redact generated SQL unless debugging in a trusted environment * keep raw query APIs out of MCP and function-calling tool definitions CLI and module loading [#cli-and-module-loading] CLI workflows that load a project API module execute application code. They are useful for local development, generation, and CI, but they are not static analysis of untrusted files. Only run model-loading CLI commands against code you trust, such as your repository in CI or a reviewed local checkout. Checklist [#checklist] * Public request bodies should use semantic field names, not SQL strings. * Raw query builder helpers should stay in server-side code. * Generated tools should be built from catalog metadata. * Tenant context should come from trusted auth/runtime state, not user filters. * SQL-backed dataset fields should be reviewed like application code. Current Audit [#current-audit] The current trusted-code escape hatches are: * `db.rawQuery(...)` * query builder `raw(...)`, `rawAs(...)`, and `selectExpr(...)` * predicate builder `expr.raw(...)` * dataset `dimension.*({ sql })` and `measure.*(..., { sql })` * internal semantic planner calls to `rawQuery(...)` for derived metric SQL assembled from validated model definitions The public semantic surfaces do not expose a generic SQL execution tool. Serve returns generated SQL only through opt-in metadata (`includeMeta` or `x-include-meta`). MCP tools redact generated SQL and SQL-backed field expressions by default, and require programmatic `includeSql: true` for trusted debugging. --- # CLI reference (/docs/reference/api/cli) import { CodeBlock } from 'fumadocs-ui/components/codeblock';
CLI reference [#cli-reference] The `hypequery` CLI provides commands for scaffolding, generating types, and running development servers. Remote ClickHouse workflows use credentials collected during `init` or supplied through the `CLICKHOUSE_*` environment variables below. The `chdb` driver runs on [embedded ClickHouse](/docs/chdb) and needs no server credentials. Which command should I use? [#which-command-should-i-use] Use the CLI commands for different jobs: | Command | Use it when | What it does | | -------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------- | | `hypequery generate` | You already have a project and need fresh schema types | Connects to ClickHouse and regenerates your TypeScript schema file | | `hypequery init` | You want the CLI to scaffold an `analytics/` folder for you | Writes starter files like `client.ts`, `schema.ts`, and `queries.ts` | | `hypequery dev` | You already have queries and want a local runtime server | Runs the local `@hypequery/serve` dev server with docs and hot reload | The most common confusion is between `init` and `generate`: * `init` scaffolds a project structure * `generate` refreshes schema types from your current ClickHouse database If you have already created your analytics folder and only need updated types, run `hypequery generate`. Installation [#installation] **No installation required!** Run commands directly with `npx`: ```bash npx @hypequery/cli init npx @hypequery/cli dev ``` For frequent use, install as a dev dependency: ```bash npm install -D @hypequery/cli # or pnpm add -D @hypequery/cli ``` Then use the shorter `hypequery` command: ```bash npx hypequery init npx hypequery dev ``` TypeScript support [#typescript-support] `hypequery dev` can load `.ts` and `.tsx` query files directly. The CLI bundles your entry file in-process before importing it, so no separate TypeScript runtime is required. If you already compile to JavaScript, you can still target the generated `.js` file instead. Commands at a glance [#commands-at-a-glance] | Command | Purpose | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `hypequery init` | Scaffold the `analytics/` folder, configure the selected database driver, and optionally create an example query or dataset | | `hypequery dev [file]` | Run the local dev server backed by `@hypequery/serve` with hot reload | | `hypequery generate` | Rebuild `analytics/schema.ts` from your current ClickHouse schema | | `hypequery generate:types` | Alias for `hypequery generate` | | `hypequery generate:datasets` | Generate dataset (semantic layer) definitions from your ClickHouse schema | | `hypequery deployment:build ` | Build canonical deployment JSON and its domain-separated identity | | `hypequery deployment:validate ` | Strictly validate deployment JSON and report its identity | | `hypequery help [command]` | Show help for the CLI or a specific command | The sections below expand on the supported options and expected behavior for each command. hypequery init [#hypequery-init] ```bash # Without installation npx @hypequery/cli init [options] # With installation npx hypequery init [options] ``` **Options:** * **`--path `** – Target directory for the scaffold (`client.ts`, `schema.ts`, and either `queries.ts` or `datasets.ts`/`api.ts`). Defaults to `analytics/` * **`--style