Sequelize
Protected Sequelize model isolation for PostgreSQL and MySQL.
tenancyjs-adapter-sequelize provides row-level, schema-per-tenant, and database-per-tenant isolation
on PostgreSQL. This page shows it wired end to end with Express; for another
framework, swap only the integration (see below).
Install
npm install tenancyjs-core tenancyjs-adapter-sequelize tenancyjs-integration-express sequelizeWire it into your app
Create the manager + adapter
Register your tenant-scoped models. On PostgreSQL, set up a forced RLS policy on each tenant table (the
database backstop) - the adapter's validate() checks the contract is in place.
import { TenancyManager } from "tenancyjs-core";
import { createSequelizeTenancy } from "tenancyjs-adapter-sequelize";
import { sequelize, Order } from "./models";
export interface Tenant {
readonly id: string;
}
export const manager = new TenancyManager<Tenant>();
export const tenancy = createSequelizeTenancy({
manager,
sequelize,
tenantModels: [
{
model: Order,
table: "app.orders",
tenantAttribute: "tenantId",
tenantColumn: "tenant_id",
},
],
});
await tenancy.validate();Bind it to requests
import express from "express";
import { createExpressTenancyMiddleware } from "tenancyjs-integration-express";
import { manager, tenancy } from "./tenancy";
import { Order } from "./models";
const app = express();
app.use(createExpressTenancyMiddleware({ manager, resolver }));
app.get("/orders", async (_req, res) => {
const orders = await tenancy.run((client) => client.model(Order).findAll());
res.json(orders); // only the current tenant's rows
});The facade rejects unsafe criteria - including Symbol-keyed operators (Op.*) that would otherwise slip
past a plain-object guard - and forced Postgres RLS is a second backstop, so a query can't escape its
tenant scope. Outside a tenant scope, it fails closed.
Other strategies
Schema mode requires models without a fixed schema and unqualified table config:
createSequelizeTenancy({
manager,
sequelize,
strategy: "schemaPerTenant",
schema: (tenant) => tenant.schemaName,
tenantModels: [{ model: Order, table: "orders" }],
});Database mode leases tenant Sequelize instances. Each instance must register the configured model name:
createSequelizeTenancy({
manager,
sequelize: landlordSequelize,
strategy: "databasePerTenant",
tenantModels: [{ model: Order, table: "orders" }],
connection: (tenant) => ({
key: tenant.databaseKey,
create: () => createTenantSequelize(tenant.databaseSecretRef),
}),
});Call validate() before traffic and close() on shutdown. Native models, instances, includes,
operators/literals, QueryInterface, raw SQL, sync, and migrations remain outside the guarantee.
For MySQL, pass dialect: "mysql". Row isolation is experimental and adapter-enforced because MySQL
has no RLS. MySQL schema and database are synonyms, so use databasePerTenant instead of schema mode.
Full query freedom: unrestricted()
In every other strategy the facade rejects raw SQL, includes, and Symbol-keyed operators - 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 that returns the leased Sequelize instance - full raw .query(), includes, and
associations:
const report = await tenancy.run(async (client) => {
// The raw, tenant-scoped Sequelize instance - raw SQL, includes, associations.
const sequelize = client.unrestricted();
const [rows] = await sequelize.query(
"SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.total > :min",
{ replacements: { min: 1000 } },
);
return rows;
});unrestricted() is fail-closed. It returns the real Sequelize instance 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 everything above; only swap the integration import: