TenancyJS
Adapters

Lucid (AdonisJS)

All three isolation strategies on PostgreSQL with AdonisJS Lucid.

tenancyjs-adapter-lucid brings the full strategy set - row-level, schema-per-tenant, and database-per-tenant - to AdonisJS Lucid on PostgreSQL. It pairs with the AdonisJS integration, which scopes every request for you.

The fastest path is npx tenancyjs-cli init - it scaffolds AdonisJS + Lucid end to end. This page covers the manual wiring and the strategy options.

Install

npm install tenancyjs-core tenancyjs-adapter-lucid tenancyjs-integration-adonis

Configure the adapter

config/tenancy.ts
import { TenancyManager } from "tenancyjs-core";
import { createLucidTenancy } from "tenancyjs-adapter-lucid";
import db from "@adonisjs/lucid/services/db";
import Order from "#models/order";
import Post from "#models/post";

export interface Tenant {
  readonly id: string;
}

export const manager = new TenancyManager<Tenant>();

export const tenancy = createLucidTenancy({
  manager,
  database: db,
  strategy: "rowLevel", // "schemaPerTenant" | "databasePerTenant"
  // Register each tenant-scoped Lucid model. Optional per-entry:
  // { model, table?, tenantColumn?, tenantAttribute?, policyName? }.
  tenantModels: [{ model: Order }, { model: Post }],
});

Register the AdonisJS integration provider + middleware (that section has the exact adonisrc.ts and start/kernel.ts lines). Each request then resolves its tenant and runs scoped, so your Lucid models are isolated automatically - no per-query tenant filters:

// inside a controller - already tenant-scoped by the middleware
const orders = await Order.all();

Use instance methods for writes, not bulk query-builder statements. The adapter scopes model operations through Lucid's hooks - Model.create, Model.all, Model.find*, .paginate, and row.save() / row.delete() (the instance delete). A bulk query-builder write like Post.query().delete() or Post.query().update({...}) bypasses those hooks, so it runs unscoped (on schema/database-per-tenant it lands on the wrong placement and fails loudly rather than crossing tenants - but it's still wrong). For bulk work inside a scope, iterate instance methods, or use scope.unrestricted() in a database-per-tenant or forced-RLS row-level scope.

Schema per tenant

Each tenant gets its own Postgres schema via a transaction-local search_path, with an optional per-tenant role for database-enforced isolation.

export const tenancy = createLucidTenancy({
  manager,
  database: db,
  strategy: "schemaPerTenant",
  schema: (tenant) => `tenant_${tenant.id}`,
});

Database per tenant

Lucid leases a { transaction, destroy } connection per tenant from a bounded cache; model queries run through it via useTransaction.

export const tenancy = createLucidTenancy({
  manager,
  database: db,
  strategy: "databasePerTenant",
  connection: (tenant) => ({
    key: tenant.connection,
    create: () => openConnectionFor(tenant),
  }),
});

See Database per tenant and Schema per tenant for how each is enforced.

Works on MySQL too - database-per-tenant only. Row-level and schema-per-tenant are PostgreSQL-only (they rely on forced RLS / search_path), but database-per-tenant isolates purely by routing each tenant to its own leased connection, so it runs on MySQL just as well (point your connection factory at MySQL). It's covered by a two-tenant adversarial test on MySQL. scope.unrestricted() works there too.

Full query freedom: scope.unrestricted()

scope.unrestricted() returns the tenant-scoped TransactionClientContract - full rawQuery, query builder, and nested 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 (the transaction runs under a non-BYPASSRLS role bound by the validated RLS policy, so it cannot cross tenants). Everywhere else your Lucid models are constrained to the tenant-safe path - in schema-per-tenant, central mode, and MySQL row-level (no RLS backstop) that path is the only guard, so it can't let through what it can't prove is tenant-safe (see Limitations). The run callback's scope argument is where you reach it:

const report = await tenancy.run(async (scope) => {
  // The raw, tenant-scoped transaction - rawQuery, query builder, nested writes.
  const trx = scope.unrestricted();

  return trx.rawQuery(
    "SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.total > ?",
    [1000],
  );
});

Existing zero-arg callbacks (tenancy.run(() => Order.all())) keep working unchanged - the scope argument is optional.

rawQuery returns the driver's native result, so the shape differs by database: on PostgreSQL (pg) it's { rows: [...] }; on MySQL (mysql2) it's [rows, fields]. Unwrap accordingly, or use the transaction's query builder (trx.from("posts")…) for a uniform array of rows.

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