TenancyJS
Adapters

Drizzle

Protected Drizzle isolation for PostgreSQL and MySQL.

tenancyjs-adapter-drizzle supports all three PostgreSQL strategies and MySQL row-level plus database-per-tenant. It exposes only callback-scoped plain-value CRUD/count methods; native Drizzle databases, transactions, SQL expressions, joins, relations, and migrations stay outside the boundary.

Install

npm install tenancyjs-core tenancyjs-adapter-drizzle drizzle-orm

Add pg for PostgreSQL or mysql2 for MySQL.

PostgreSQL row-level

import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import {
  createDrizzleTenancy,
  createPostgresDrizzleBinding,
} from "tenancyjs-adapter-drizzle";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const tenancy = createDrizzleTenancy({
  manager,
  database: createPostgresDrizzleBinding(drizzle({ client: pool })),
  tenantTables: [{ table: orders, policyName: "orders_tenant_isolation" }],
});

await tenancy.validate();

const orders = await tenancy.run((client) =>
  client.table(orders).findMany({ status: "open" }),
);

PostgreSQL row mode validates a non-privileged runtime role, enabled and forced RLS, and the reviewed policy contract before execution unlocks.

Schema and database placement

Schema mode uses an unqualified pgTable and a transaction-local search path:

createDrizzleTenancy({
  manager,
  database: createPostgresDrizzleBinding(db),
  strategy: "schemaPerTenant",
  schema: (tenant) => tenant.schemaName,
  tenantTables: [{ table: orders }],
});

Database mode resolves an opaque key and a cache-owned binding. Supply a close callback when the binding owns a tenant pool.

MySQL

Use createMySqlDrizzleBinding. Row-level is adapter-enforced and experimental: MySQL has no RLS, so using the native Drizzle database bypasses TenancyJS. MySQL schemas are databases, therefore database-per-tenant is the applicable placement strategy and schema-per-tenant is rejected.

Protected operations

The initial surface supports findMany, findOne, count, create, createMany, update, and delete with plain scalar equality. Tenant filters are composed, writes inject/validate ownership, and updates cannot move a row to another tenant.

Full query freedom: unrestricted()

unrestricted() returns the native Drizzle transaction - full joins, relations, and raw SQL expressions. 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 protected client is the only guard, so it can't let through what it can't prove is tenant-safe (see Limitations). It's generic - the binding erases the concrete Drizzle type, so you supply it at the call site:

import { eq, gt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema";

const report = await tenancy.run(async (client) => {
  // The native Drizzle transaction - pass your own db type; full joins, relations, raw SQL.
  const trx = client.unrestricted<NodePgDatabase<typeof schema>>();

  return trx
    .select({ id: schema.orders.id, name: schema.customers.name })
    .from(schema.orders)
    .innerJoin(schema.customers, eq(schema.customers.id, schema.orders.customerId))
    .where(gt(schema.orders.total, 1000));
});

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

On this page