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",
// A record keyed by table name (value carries optional tenantColumn / policyName):
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, // a TenantResolutionChain (tenancyjs-identifiers) - see the Express guide
}),
);
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()
unrestricted() returns the raw, tenant-scoped Knex transaction - full joins, raw SQL, and nested
reads/writes. It's available in the two scopes where the database itself keeps a query inside the
tenant: database-per-tenant (the leased connection is the tenant's own database) and
forced-RLS row-level on PostgreSQL (raw SQL runs under a non-BYPASSRLS role bound by the validated
RLS policy, so it cannot cross tenants). It's still refused in schema-per-tenant, central mode, and
MySQL row-level (no RLS backstop) - there the scoped client is the only guard, so it can't let through
what it can't prove is tenant-safe (see Limitations):
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 a real transaction only where the database itself
enforces the tenant boundary - a database-per-tenant config in tenant mode (a per-tenant connection
was actually leased), or forced-RLS row-level on PostgreSQL (raw SQL is bound by the validated policy
under a non-BYPASSRLS role). It throws in schema-per-tenant scopes, in MySQL row-level (no RLS
backstop), and in any config used in central mode (which runs on the shared admin connection, not a
tenant's database). The freedom comes from a database boundary, never from the config name
(ADR-0038).
Use a different framework
Keep the adapter half; only swap the integration import: