Multi-tenancy
@happyvertical/smrt-tenancy isolates each tenant's data without threading a tenant
ID through every query. You mark a class as tenant-scoped, run your request inside a tenant
context, and a query interceptor adds the WHERE tenant_id = … filter automatically. For
Postgres, you can add row-level security as a database-enforced second layer.
Two layers of isolation
SMRT separates application-level scoping from database-level enforcement:
| Layer | Provided by | What it does |
|---|---|---|
| Query interceptor | smrt-tenancy | Rewrites every list/get/save/delete for a tenant-scoped class to filter and
auto-populate tenantId from the active context. |
| Postgres RLS | smrt-users | Generates CREATE POLICY statements from your permission catalog so the database
rejects cross-tenant rows even if app code has a bug. |
1. Mark a class as tenant-scoped
Enable tenancy once at startup with enableTenancy(). Then annotate each
tenant-owned class with @TenantScoped() and its tenant column with the @tenantId() property decorator.
import { enableTenancy, TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
// Once, at app startup
enableTenancy();
@smrt()
@TenantScoped({ mode: 'required' })
class Document extends SmrtObject {
@tenantId()
tenantId: string = '';
title: string = '';
body: string = '';
}@TenantScoped takes one option, mode:
| Mode | Behavior |
|---|---|
'required' (default) | Every operation must run inside a tenant context. Missing context throws — fail-closed. |
'optional' | Works with or without a context. With no tenant, rows with a null tenant ID act as global records. |
For an optional-tenancy class, make the field nullable so global rows are representable:
@smrt()
@TenantScoped({ mode: 'optional' })
class Template extends SmrtObject {
@tenantId({ nullable: true })
tenantId: string | null = null; // null = shared/global template
name: string = '';
}2. Run inside a tenant context
Wrap a unit of work in withTenant(). Inside the callback, every query against a
tenant-scoped collection is automatically filtered, and creates auto-populate the tenant ID.
import { withTenant } from '@happyvertical/smrt-tenancy';
await withTenant({ tenantId: 'tenant-123' }, async () => {
// Auto-filtered: WHERE tenant_id = 'tenant-123' AND status = 'active'
const docs = await documents.list({ where: { status: 'active' } });
// tenantId is set for you — no need to pass it
const doc = await documents.create({ title: 'Q3 report' });
console.log(doc.tenantId); // 'tenant-123'
});Read the active context, or assert it, anywhere inside the callback:
import { getTenantId, requireTenantId } from '@happyvertical/smrt-tenancy';
const maybe = getTenantId(); // string | null
const id = requireTenantId(); // string, or throws if no context3. Enforce with Postgres RLS (optional)
On Postgres, @happyvertical/smrt-users can generate and apply row-level security policies
directly from your permission catalog, so the database itself rejects cross-tenant access.
import {
syncPermissionCatalog,
generatePostgresPermissionSql,
applyPostgresPermissionPolicies
} from '@happyvertical/smrt-users';
const db = { db: { type: 'postgres' as const, url: process.env.DATABASE_URL! } };
await syncPermissionCatalog(db);
// Preview the policy SQL (targets that will get policies, and skips)
const preview = generatePostgresPermissionSql(db);
console.log(preview.targets, preview.skipped);
// Apply: ALTER TABLE … ENABLE ROW LEVEL SECURITY + CREATE POLICY …
await applyPostgresPermissionPolicies(db);Automatic policy generation applies only to objects that are:
- tenant-scoped with
mode: 'required', - backed by a real Postgres table, and
- mapped to a single tenant field.
Optional-tenancy and global tables are skipped (returned in result.skipped)
rather than getting unsafe policies. The generated CRUD policies map to permission slugs: SELECT → read, INSERT → create, UPDATE → update, DELETE → delete.
Testing tenant-scoped code
The tenancy package ships test helpers so isolation behaves the same under Vitest. Enable the
interceptors in a setup file, then drive tests inside withTenant().
import { setupTestTenancy, resetTenancy, withTenant } from '@happyvertical/smrt-tenancy';
// In your test setup file
setupTestTenancy({ enableInterceptors: true });
// In a test
afterEach(() => resetTenancy());
it('auto-populates tenantId', async () => {
await withTenant({ tenantId: 'test-tenant' }, async () => {
const doc = await documents.create({ title: 'Widget' });
expect(doc.tenantId).toBe('test-tenant');
});
});Related
- @happyvertical/smrt-tenancy — decorators, context runners, adapters.
- @happyvertical/smrt-users — sessions, permissions, and the Postgres RLS generators.
- Guide: multi-tenant request lifecycle —
one
hooks.server.tsthat ties it together.
Verified against SMRT v0.29.34.