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-adonisConfigure the adapter
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).