Configuration
Define a tenancy.config.ts runtime so the CLI and your app share one source of truth.
The operational CLI reaches your live tenants by loading a tenancy.config.ts at runtime.
Node 24 strips the TypeScript types natively, so there is no transpiler dependency - the CLI stays
zero-dependency, and your config is just TypeScript.
The CLI looks for tenancy.config.ts (then .mts/.mjs/.js) in your project root, or wherever you
point it with --config <path>.
defineTenancyRuntime
Export a runtime from your config. It's the single contract the CLI reads - it never reaches into framework internals.
import { Pool } from "pg";
import { defineTenancyRuntime } from "tenancyjs-core";
import { manager, tenancy } from "./tenancy";
import { store } from "./tenant-store";
// A privileged admin connection the CLI uses to create/drop tenant placements.
const admin = new Pool({ connectionString: process.env.ADMIN_DATABASE_URL });
export default defineTenancyRuntime({
manager, // required - your TenancyManager
store, // optional - powers the registry commands (list/create/…)
adapters: [tenancy], // optional - powers `tenant check` capability reporting
provisioner: {
// These run when you call `tenancy tenant provision|migrate|deprovision`.
// This example is schema-per-tenant on Postgres - swap the DDL for your
// strategy (database-per-tenant → CREATE DATABASE; row-level → usually a
// no-op provision + a migrate that seeds the shared tables).
provision: async (tenant) => {
await admin.query(`create schema if not exists "tenant_${tenant.id}"`);
},
migrate: async (tenant) => {
// Run your ORM's migrator against the tenant's placement. E.g. Knex:
// await knex.migrate.latest({ schemaName: `tenant_${tenant.id}` });
// or shell out to your framework's migration command with the schema set.
await runMigrationsFor(`tenant_${tenant.id}`);
},
deprovision: async (tenant) => {
await admin.query(`drop schema if exists "tenant_${tenant.id}" cascade`);
},
},
dispose: async () => {
await admin.end(); // close connections so the CLI exits cleanly
},
});For database-per-tenant, provision becomes create database "tenant_${tenant.id}" (run against a
maintenance database), and deprovision drops it. For row-level, provisioning is often a no-op -
all tenants share the tables - and migrate just ensures the shared schema + RLS
policies exist.
Everything except manager is optional; a command that needs an absent piece fails with a clear
message (e.g. "your runtime has no provisioner") instead of doing something surprising.
Bring-your-own tenant store
TenancyJS does not own where tenants live - your table, your API, your Prisma model. A TenantStore
implements only the methods you support; commands that need a missing one degrade with a clear "not
supported by your store" error.
import type { TenantStore } from "tenancyjs-core";
export const store: TenantStore<Tenant> = {
list: () => db.tenant.findMany(),
find: (id) => db.tenant.findUnique({ where: { id } }),
create: (input) => db.tenant.create({ data: input }),
suspend: (id) => db.tenant.update({ where: { id }, data: { status: "suspended" } }),
activate: (id) => db.tenant.update({ where: { id }, data: { status: "active" } }),
delete: (id) => db.tenant.delete({ where: { id } }),
};The store is hardened at the boundary. A find(id) that returns a tenant whose id doesn't match,
a list() with duplicate ids, or a create that doesn't echo the requested id is rejected -
a buggy store can't leak one tenant's data under another's identity. This holds even for a hand-built
config, because the CLI re-hardens the store on load.
Placement lives on the record
The only field TenancyJS requires on a tenant is id. Everything else is yours - including
placement: for schema- and database-per-tenant, put each tenant's schema name or connection reference
on the tenant record your store returns. Your connection(tenant) / schema(tenant) callbacks and your
provisioner hooks read those fields; the library never does. So there's one source of truth for where a
tenant's data lives, and you name the fields.
interface Tenant {
readonly id: string; // required - the only field TenancyJS itself reads
// everything below is host-defined; name it however you like:
readonly name?: string;
readonly status?: "active" | "suspended";
readonly schemaName?: string; // read by your schema(tenant) callback (schema-per-tenant)
readonly databaseKey?: string; // read by your connection(tenant) callback (database-per-tenant)
}Secrets are redacted
Every operational command redacts secrets - connection strings, passwords, tokens - from both human and
--json output. You don't have to scrub anything yourself.
Once the config exists, the CLI can act:
npx tenancyjs-cli tenant check # verify the runtime + warn on untested combos
npx tenancyjs-cli tenant list