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-injectautomatically applies tenant filters to compatible builders in contextmanualleaves 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: falsemakes tenant context optional for that querymode: 'manual'disables auto-injection for that query- if no global tenant config exists, include
extractin 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.extractandtenant.requiredsupply the trusted tenant identity from auth - the filter column comes from the dataset's own
tenantKey, declared ondataset(...) - datasets auto-inject the tenant predicate from
tenantKey, just likecreateDatasetClientdoes 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.