Knex
All three isolation strategies on PostgreSQL with Knex - wired end to end into any framework.
tenancyjs-adapter-knex is the most complete adapter - it supports all three strategies on
PostgreSQL. This page shows row-level with Express end to end, then the other strategies. Different
framework? Swap only the integration (see below).
Install
npm install tenancyjs-core tenancyjs-adapter-knex tenancyjs-integration-express knexWire it into your app (row-level)
Create the manager + adapter
import { TenancyManager } from "tenancyjs-core";
import { createKnexTenancy } from "tenancyjs-adapter-knex";
import Knex from "knex";
export interface Tenant {
readonly id: string;
}
export const manager = new TenancyManager<Tenant>();
const knex = Knex({ client: "pg", connection: process.env.DATABASE_URL });
export const tenancy = createKnexTenancy({
manager,
knex,
strategy: "rowLevel",
tenantTables: ["orders", "posts"],
});On PostgreSQL, add a forced RLS policy to each tenant table (the database backstop).
Bind it to requests
Inside a request, run your queries through tenancy.run - it hands you a tenant-scoped Knex.
import express from "express";
import { createExpressTenancyMiddleware } from "tenancyjs-integration-express";
import { manager, tenancy } from "./tenancy";
const app = express();
app.use(
createExpressTenancyMiddleware({
manager,
resolver: (req) => ({ id: req.subdomains.at(-1) ?? "" }),
}),
);
app.get("/orders", async (_req, res) => {
const orders = await tenancy.run((db) => db("orders").select("*"));
res.json(orders); // only the current tenant's rows
});Schema per tenant
Each tenant gets its own Postgres schema, selected per transaction via search_path. Add a per-tenant
role for database-enforced isolation.
export const tenancy = createKnexTenancy({
manager,
knex,
strategy: "schemaPerTenant",
schema: (tenant) => `tenant_${tenant.id}`,
// optional: role: (tenant) => `tenant_${tenant.id}_role`,
});Database per tenant
Route a dedicated connection per tenant, pooled and reused through a bounded, single-flight cache.
export const tenancy = createKnexTenancy({
manager,
knex,
strategy: "databasePerTenant",
connection: (tenant) => ({
key: tenant.database,
create: () => Knex({ client: "pg", connection: tenant.databaseUrl }),
}),
});Cross-placement access is rejected, central scope uses the base connection, and every leased connection is disposed on teardown. See Database per tenant.
Full query freedom: unrestricted()
In every other strategy the scoped client rejects raw SQL, joins, and nested queries - it's the only guard, so it can't let through what it can't prove is tenant-safe (see Limitations). Database-per-tenant is different: the leased connection is the tenant's own database, so any query is isolated by construction. There, the client gives you an escape hatch:
const report = await tenancy.run(async (db) => {
// The raw, tenant-scoped Knex transaction - full joins, raw SQL, nested reads/writes.
const trx = db.unrestricted();
return trx("orders")
.join("customers", "customers.id", "orders.customer_id")
.whereRaw("orders.total > ?", [1000])
.select("orders.id", "customers.name");
});unrestricted() is fail-closed. It returns the real transaction only in a genuinely
database-enforced scope - a database-per-tenant config running in tenant mode, where a per-tenant
connection was actually leased. It throws in row-level and schema-per-tenant scopes, and even in a
database-per-tenant config used in central mode (which runs on the shared admin connection, not a
tenant's database). The freedom comes from the connection boundary, never from the config name
(ADR-0033).
Use a different framework
Keep the adapter half; only swap the integration import: