TenancyJS
Adapters

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 sequelize

Wire 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.

tenancy.ts
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

server.ts
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, // a TenantResolutionChain (tenancyjs-identifiers) - see the Express guide
  }),
);

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()

unrestricted() returns a tenant-scoped { sequelize, transaction } handle - run raw .query(), includes, and associations on the transaction. 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 (the transaction 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 facade 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 (client) => {
  // A tenant-scoped handle - pass the transaction to every raw call so it stays scoped.
  const { sequelize, transaction } = 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 }, transaction },
  );
  return rows;
});

unrestricted() is fail-closed. It returns a real { sequelize, transaction } handle 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 (the transaction 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 everything above; only swap the integration import:

On this page