TenancyJS
GuidesProvisioning per ORM

Provisioning per ORM

Batteries-included recipes for creating and migrating each tenant's placement with your own ORM's tooling.

TenancyJS routes every query to the right tenant's placement and fails closed when there is no scope. What it deliberately does not do is run CREATE SCHEMA / CREATE DATABASE or your migrations for you - creating a tenant's storage is your ORM's job, run through provisioner hooks that the tenancy CLI drives. That's the one manual seam: batteries-included, but not zero-config.

These recipes fill that seam. Each one is a concrete, copy-adaptable implementation of the three hooks for one ORM, wired so tenancy tenant provision | migrate | deprovision just work. They're written to be followed by a human or an AI assistant - paste your TENANCY.md context (from tenancyjs-cli init --ai-context) and hand your agent the recipe for your stack.

Setup-agent prompt

Hand this to your AI assistant to wire tenant provisioning end to end — creating and migrating each tenant's schema/database through the provisioner hooks — and have it write out everything you still own as an action-items checklist.

# TenancyJS Provisioning Agent

You are a provisioning agent. Wire tenant provisioning into this project so
`tenancy tenant provision | migrate | deprovision` create, migrate, and drop each tenant's storage.
Execute every step in order; after each, verify before continuing. Do NOT invent APIs — fetch the docs.

Source of truth (fetch these):
- https://tenancyjs.pages.dev/docs/getting-started/configuration   (defineTenancyRuntime + provisioner hooks)
- https://tenancyjs.pages.dev/docs/guides/provisioning             (read the recipe for THIS project's ORM)
- https://tenancyjs.pages.dev/docs/guides/onboarding-tenants
- https://tenancyjs.pages.dev/docs/guides/testing-isolation

## Pre-flight
- Node >= 24. Detect the ORM (Prisma/Knex/Lucid/TypeORM/Sequelize/Drizzle/Mongoose) and the strategy.
- If the strategy is row-level, STOP and say so: row-level shares tables, there is nothing to provision.
- Fetch and read the provisioning recipe for the detected ORM before writing code.

## Step 1 — Placement on the tenant record
Ensure each tenant row carries its placement: a `schemaName` (schema-per-tenant) or an opaque
`databaseKey` (database-per-tenant — never a URL or credentials). Add it to the store's create input.

## Step 2 — Admin/maintenance connection
Create a PRIVILEGED connection (a `pg`/`mysql2` admin client) that can run DDL, from `ADMIN_DATABASE_URL`.
Keep it separate from the fail-closed runtime role — never run DDL through the runtime role.

## Step 3 — Provisioner hooks
In `tenancy.config.ts`, implement `provision` / `migrate` / `deprovision` from the ORM recipe:
- provision: `CREATE SCHEMA`/`CREATE DATABASE`, idempotent (`if not exists`).
- migrate: run the ORM's OWN migrator against the tenant's placement (differs per ORM — see the recipe).
- deprovision: drop it (destructive; keep behind confirmation).
Pass them to `defineTenancyRuntime({ manager, store, adapters, provisioner, dispose })`; `dispose` closes
the admin connection.

## Step 4 — Run the lifecycle
`npx tenancyjs-cli tenant create acme --set <placement>=...` → `tenant provision acme` → `tenant migrate acme`.
Confirm each succeeds.

## Step 5 — Prove isolation
`npx tenancyjs-cli test:leak --test-file <path>` — two tenants, same PK, no cross-read, unscoped access throws.

## Step 6 — Write the action items
Create `PROVISIONING_NEXT_STEPS.md` with an ACTION ITEMS checklist of everything the human still owns:
- [ ] Set ADMIN_DATABASE_URL (privileged) and the runtime DATABASE_URL (non-owner, non-BYPASSRLS) per env
- [ ] Call `tenant provision` + `tenant migrate` from onboarding (signup handler / admin action)
- [ ] Roll new migrations to every tenant in CI: `tenancy tenant migrate --all`
- [ ] Back up before `deprovision`, and keep it behind confirmation
- [ ] Prove isolation in CI with the two-tenant leak test
- [ ] (schema-per-tenant) add a per-tenant role for database-enforced isolation, if you need it
For anything you could NOT complete, add it as a blocked action item with the reason.

Report a summary of every file changed, and paste the contents of PROVISIONING_NEXT_STEPS.md.

Batteries-included (PostgreSQL)

For PostgreSQL you don't have to hand-write the provision/deprovision DDL — pass a ready provisioner from tenancyjs-adapter-shared and point migrate at your own migrator. admin is a privileged pg-shaped connection (its .query()), kept separate from the fail-closed runtime role.

tenancy.config.ts
import { createPostgresSchemaProvisioner } from "tenancyjs-adapter-shared";
import { Pool } from "pg";

const admin = new Pool({ connectionString: process.env.ADMIN_DATABASE_URL });

export default defineTenancyRuntime({
  manager,
  store,
  provisioner: createPostgresSchemaProvisioner({
    admin,
    schema: (tenant) => `tenant_${tenant.id}`,
    // TenancyJS never runs your ORM — point this at your migrator for the schema.
    migrate: (tenant, { schema }) => runMyMigrations({ schema }),
  }),
});

createPostgresDatabaseProvisioner({ admin, database, migrate }) is the database-per-tenant twin (admin connects to a maintenance database like postgres). Both validate the placement name as a SQL identifier, so a tenant-derived name can't inject DDL. Prefer these over hand-writing the hooks below; the manual recipe remains for other databases or custom placement logic.

The shape of every recipe

Whatever the ORM, onboarding a tenant is the same four moves:

Record - store.create

Write the tenant row (id + its placement: schema name or database key). Covered once in Configuration → bring-your-own store; the recipes assume it's in place.

Provision - provisioner.provision

Create the placement: CREATE SCHEMA for schema-per-tenant, CREATE DATABASE for database-per-tenant. Row-level shares tables, so there's nothing to provision.

Migrate - provisioner.migrate

Bring the fresh placement up to your current schema by running your ORM's own migrator against it. This is the part that differs most per ORM - it's the heart of each recipe.

Route - the adapter

At request time the adapter leases the tenant's connection and scopes the query. That's configured on the adapter page for your ORM; nothing extra to do here.

Then prove it with tenancy test:leak before you trust it.

Pick your ORM

ORMSchema-per-tenantDatabase-per-tenantRecipe
PrismaPostgreSQLPostgreSQL · MySQLPrisma →
KnexPostgreSQLPostgreSQLKnex →
LucidPostgreSQLPostgreSQLLucid →
TypeORMPostgreSQLPostgreSQL · MySQLTypeORM →
SequelizePostgreSQLPostgreSQL · MySQLSequelize →
DrizzlePostgreSQLPostgreSQL · MySQLDrizzle →
Mongoose-MongoDBMongoose →

MySQL has no schema namespace separate from a database (SCHEMA is a synonym for DATABASE), so on MySQL the per-tenant isolation is database-per-tenant. MongoDB likewise isolates per database.

The DDL and migrator calls in these recipes run with elevated privileges (a maintenance/admin connection that can create schemas or databases). Keep that connection separate from your fail-closed runtime role - the runtime role must not own tables or bypass RLS.

On this page