> hypequery

Multi-Tenancy

Configure tenant extraction and isolation in the serve({ queries }) runtime.

Multi-Tenancy Isolation

Tenant configuration is enforced by the serve({ queries }) runtime and applies to the query definitions you expose there.

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

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

type AppAuth = { userId: string; tenantId: string };

const authStrategy = async ({ request }): Promise<AppAuth | null> => {
  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

extract

Use extract to read a tenant ID from your auth object.

column

Set the database column used for tenant filtering when you use mode: 'auto-inject'.

mode

  • auto-inject automatically applies tenant filters to compatible builders in context
  • manual leaves tenant filtering up to your query logic

required

When required is true, requests without a tenant ID are rejected.

Auto-inject mode

Auto-inject mode is the safest option because it makes tenant filtering the runtime default:

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

Use manual mode when you need to control tenant predicates yourself:

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

Object-style query({ ... }) supports per-query tenant overrides.

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

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.

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

On this page