> hypequery

Core concepts

Understand the builder, datasets (semantic layer), and runtime in hypequery.

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

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.

LayerUse it whenAdds
Query builderQuery logic is local to one placeTyped ClickHouse query construction and execution
Datasets (semantic layer)Logic needs reuse and consistencyDimensions, measures, metrics, shared filters
RuntimeLogic needs an application surfaceRoutes, validation, auth, docs, integrations

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
import { createQueryBuilder } from '@hypequery/clickhouse';
import type { IntrospectedSchema } from './generated-schema';

const db = createQueryBuilder<IntrospectedSchema>({
  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)

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.

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)

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
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

Stage 1 — local query

const activeUsers = await db
  .table('users')
  .select(['id', 'created_at'])
  .where('status', 'eq', 'active')
  .execute();

Stage 2 — dataset

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

export const api = serve({
  datasets: { users: Users },
  queryBuilder: db,
});

You can also expose hand-authored builder queries through the same runtime:

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

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

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.

On this page