TenancyJS
Getting Started

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.

tenancy.config.ts
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.

tenant-store.ts
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

On this page