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 tenancy 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";

export interface Tenant {
  readonly id: string;
}

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

export const tenancy = createLucidTenancy({
  manager,
  database: db,
  strategy: "rowLevel", // "schemaPerTenant" | "databasePerTenant"
  tenantTables: ["orders", "posts"],
});

Register the AdonisJS integration provider + middleware (the init scaffold does both). 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();

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

In every other strategy your Lucid models are constrained to the tenant-safe path - 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 run callback's scope argument gives you an escape hatch that returns the leased TransactionClientContract - full rawQuery, query builder, and nested writes:

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.

scope.unrestricted() is fail-closed. It returns the real transaction 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).

On this page