Skip to content

@stratum-hq/db-adapters

@stratum-hq/db-adapters provides PostgreSQL adapters that automatically scope queries to the current tenant using Row-Level Security. It supports raw pg, Prisma, and Sequelize, plus helpers for enabling RLS on your tables.

npm install @stratum-hq/db-adapters pg

For applications using pg (node-postgres) directly:

import { Pool } from "pg";
import { RawAdapter, createTenantPool } from "@stratum-hq/db-adapters";
import { getTenantContext } from "@stratum-hq/sdk";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Option 1: Automatic context from AsyncLocalStorage
const tenantPool = createTenantPool(pool, () => getTenantContext().tenant_id);
const result = await tenantPool.query("SELECT * FROM orders");
// Option 2: Manual adapter usage
const adapter = new RawAdapter(pool);
const result = await adapter.query("tenant-id", "SELECT * FROM orders");

The adapter wraps every query in a transaction that:

  1. Calls BEGIN
  2. Sets app.current_tenant_id via parameterized set_config(), fully SQL-injection-safe
  3. Executes your query (automatically filtered by RLS policies)
  4. Calls COMMIT
  5. Resets the tenant context and releases the connection

For a full tutorial with Docker setup, RLS policies, and Express integration, see the Prisma + PostgreSQL RLS guide.

For applications using Prisma ORM:

import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
import { prismaWithTenant } from "@stratum-hq/db-adapters";
import { getTenantContext } from "@stratum-hq/sdk";
const prisma = new PrismaClient();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const tenantPrisma = prismaWithTenant(
prisma,
() => getTenantContext().tenant_id,
pool,
);
// All queries automatically scoped to the current tenant
const orders = await tenantPrisma.order.findMany();

The Prisma helpers support Prisma 5, 6 and 7: prismaWithTenant, PrismaAdapter, SchemaPrismaAdapter and DatabasePrismaAdapter.

Prisma 7 removed the datasources option of the PrismaClient constructor. A Prisma 7 client connects through a driver adapter, such as PrismaPg from @prisma/adapter-pg. prismaWithTenant takes the client that you make, so it needs no change.

SchemaPrismaAdapter gives each tenant a Prisma client bound to the tenant schema, tenant_<slug>. DatabasePrismaAdapter gives each tenant a Prisma client bound to the tenant database, stratum_tenant_<slug>. Both cache up to 50 clients and disconnect the least recently used client.

With Prisma 5 and 6, give the PrismaClient class and the datasource URL. The adapters set the tenant schema or database in the URL:

import { PrismaClient } from "@prisma/client";
import { DatabasePoolManager, DatabasePrismaAdapter, SchemaPrismaAdapter } from "@stratum-hq/db-adapters";
const poolManager = new DatabasePoolManager({
baseConnectionConfig: { connectionString: process.env.DATABASE_URL },
});
const schemas = new SchemaPrismaAdapter(PrismaClient, process.env.DATABASE_URL!);
const databases = new DatabasePrismaAdapter(poolManager, PrismaClient, process.env.DATABASE_URL!);
const orders = await schemas.getClient("acme").order.findMany();

Prisma 7 ignores a schema parameter in the connection URL, so a URL alone cannot route a tenant to its schema. With Prisma 7, also give the driver adapter class. The adapters make each tenant client with new PrismaClient({ adapter: new PrismaPg({ connectionString }, { schema }) }):

import { PrismaClient } from "./generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { DatabasePoolManager, DatabasePrismaAdapter, SchemaPrismaAdapter } from "@stratum-hq/db-adapters";
const poolManager = new DatabasePoolManager({
baseConnectionConfig: { connectionString: process.env.DATABASE_URL },
});
const schemas = new SchemaPrismaAdapter(PrismaClient, process.env.DATABASE_URL!, {
driverAdapter: PrismaPg,
maxClients: 50,
});
const databases = new DatabasePrismaAdapter(poolManager, PrismaClient, process.env.DATABASE_URL!, {
driverAdapter: PrismaPg,
});
const orders = await schemas.getClient("acme").order.findMany();

SchemaPrismaAdapter gives the driver adapter the tenant schema. DatabasePrismaAdapter gives it the tenant database URL, and the schema parameter of your URL when the URL has one. Any driver adapter class with the constructor new (config, { schema }) works. If you give a Prisma 7 client without driverAdapter, getClient throws an error that names the driverAdapter option, and makes no client.

import { enableRLS, createPolicy } from "@stratum-hq/db-adapters";
const client = await pool.connect();
try {
await enableRLS(client, "orders"); // ENABLE + FORCE ROW LEVEL SECURITY
await createPolicy(client, "orders"); // CREATE POLICY tenant_isolation
} finally {
client.release();
}

Before it adds tenant_isolation, or keeps an existing policy with that name, createPolicy checks every policy already on the table. PostgreSQL ORs permissive policies together, so every permissive policy, whatever its name or roles, must compare tenant_id with the current tenant setting app.current_tenant_id for the commands it covers. Restrictive policies may check anything. An existing tenant_isolation policy must also be permissive, apply to all commands, and apply to PUBLIC (no TO clause). If any check fails, createPolicy throws an error that names each policy and its expression, and changes nothing. A correct policy written in a form the check does not recognize is refused too. Drop or correct those policies, then call createPolicy again.

import { dropPolicy, disableRLS } from "@stratum-hq/db-adapters";
await dropPolicy(client, "orders");
await disableRLS(client, "orders");
import { isRLSEnabled } from "@stratum-hq/db-adapters";
const enabled = await isRLSEnabled(client, "orders"); // true/false

One-step table migration that adds tenant_id, enables RLS, and creates the isolation policy:

import { migrateTable } from "@stratum-hq/db-adapters";
const client = await pool.connect();
try {
await client.query("BEGIN");
await migrateTable(client, "orders");
await client.query("COMMIT");
} finally {
client.release();
}

Or step-by-step for more control:

import {
addTenantColumn,
enableRLS,
createIsolationPolicy,
} from "@stratum-hq/db-adapters";
await addTenantColumn(client, "orders"); // ALTER TABLE ADD tenant_id UUID NOT NULL
await enableRLS(client, "orders"); // ENABLE + FORCE ROW LEVEL SECURITY
await createIsolationPolicy(client, "orders"); // CREATE POLICY tenant_isolation

Low-level functions for managing the PostgreSQL session variable:

import {
setTenantContext,
resetTenantContext,
getCurrentTenantId,
} from "@stratum-hq/db-adapters";
await setTenantContext(client, "tenant-uuid");
const id = await getCurrentTenantId(client); // "tenant-uuid"
await resetTenantContext(client); // clears the tenant and the read scope

setTenantContext sets transaction-local values, so call it inside a transaction. withTenantContext does the whole cycle for you: it takes a connection, opens a transaction, sets the tenant, runs your function and commits (or rolls back on an error):

import { withTenantContext } from "@stratum-hq/db-adapters";
const orders = await withTenantContext(pool, "tenant-uuid", (client) =>
client.query("SELECT * FROM orders"),
);
// Also read the rows of every descendant of the tenant. Writes stay limited
// to the tenant itself.
const alerts = await withTenantContext(
pool,
"tenant-uuid",
(client) => client.query("SELECT * FROM alerts"),
{ scope: "subtree" },
);

The last argument is { scope?: "exact" | "subtree" }, and setTenantContext takes the same options. The default, "exact", reads the rows of the tenant only. "subtree" needs policies that admit it: see Subtree Read Scope. An unknown scope throws before a connection is taken.

For a full tutorial with Docker setup, RLS policies, and Express integration, see the Drizzle ORM multi-tenancy guide.

For applications using Drizzle ORM:

import { Pool } from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleWithTenant } from "@stratum-hq/db-adapters";
import { getTenantContext } from "@stratum-hq/sdk";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
const tenantDb = drizzleWithTenant(
db,
() => getTenantContext().tenant_id,
pool,
);
// All queries automatically scoped to the current tenant
const result = await tenantDb.execute(db.select().from(orders));

For applications using Sequelize ORM:

import { Pool } from "pg";
import { SequelizeAdapter, sequelizeWithTenantScope } from "@stratum-hq/db-adapters";
import { getTenantContext } from "@stratum-hq/sdk";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Option 1: Convenience function, wraps every query in a tenant-scoped transaction
const tenantSequelize = sequelizeWithTenantScope(
sequelize,
() => getTenantContext().tenant_id,
pool,
);
const orders = await tenantSequelize.query("SELECT * FROM orders");
// Option 2: Manual adapter usage
const adapter = new SequelizeAdapter(pool);
const result = await adapter.executeWithTenantContext("tenant-id", async (client) => {
return client.query("SELECT * FROM orders");
});

The wrapper scopes query() only. It wraps every query() call in a Sequelize transaction that:

  1. Calls sequelize.transaction()
  2. Sets app.current_tenant_id via parameterized set_config() inside the transaction
  3. Executes your query on the same connection (guaranteed by the transaction)
  4. Commits the transaction

This ensures the set_config and your query always execute on the same database connection, preventing tenant context leaks in connection-pooled environments.

Nothing else is scoped. Model methods such as Order.findAll() run on your original Sequelize instance, and the wrapper’s transaction() and addHook() pass straight through to it, so none of them set the tenant. Run tenant queries through the wrapper’s query(), or set the tenant yourself inside the transaction. When the tenant function returns an empty string, query() throws instead of running the query.

The @stratum-hq/db-adapters/pglite entry point runs @stratum-hq/lib on PGlite, which is PostgreSQL compiled to WebAssembly. It works in Node and in the browser, and it needs no database server. Use it for fast local tests and for demos.

PGlite is an optional peer dependency. Install it yourself:

npm install @electric-sql/pglite
import { Stratum } from "@stratum-hq/lib";
import { withTenantContext } from "@stratum-hq/db-adapters";
import { createPglitePool, createRestrictedPool } from "@stratum-hq/db-adapters/pglite";
// In-memory database. Pass PGlite options, such as { dataDir: "idb://my-db" }, to keep data.
const pool = await createPglitePool();
const stratum = new Stratum({ pool, autoMigrate: true });
await stratum.initialize();
const acme = await stratum.createTenant({ name: "Acme", slug: "acme" });
// Row-level security applies only to a role that is not a superuser.
const appPool = await createRestrictedPool(pool);
await withTenantContext(appPool, acme.id, (client) =>
client.query("SELECT * FROM config_entries"),
);
Function Result
createPglitePool(source?) A pg.Pool-compatible object over one PGlite instance. source is a PGlite instance or PGlite options. When you pass options, the pool creates the instance, loads the ltree and uuid_ossp extensions, and closes the instance on end(). When you pass an instance, you load the extensions and you close it. pool.pglite is the instance.
createRestrictedPool(pool, { role? }) A pool that runs every query as the role stratum_app (or the name you give). The function creates the role with NOSUPERUSER NOBYPASSRLS. It grants read and write access to every table and sequence in the public schema. Default privileges extend the grants to tables that the superuser creates later. The restricted role is a test and demo convenience, not a security boundary: any query can leave it with RESET ROLE.
  • One connection. A client from connect() holds the only connection until release(). Other callers wait in order. release() rolls back a transaction the client left open and resets the session settings and the role, so the next client starts clean.
  • No concurrency. Queries run one at a time. Do not use this adapter to test race conditions or lock contention.
  • Superuser by default. PGlite connects as the superuser postgres, and a superuser bypasses row-level security. Use createRestrictedPool when a test must prove isolation.
  • Only part of pg.Pool. The pool supports query, connect, end and on. Callbacks, cursors, totalCount and the other pool counters do not exist.
  • PGlite ^0.4.2 is the supported version.
Property Implementation
Table name validation All DDL operations validate against /^[a-zA-Z_][a-zA-Z0-9_]*$/
Parameterized context Tenant ID set via set_config($1, true), fully parameterized
FORCE RLS enableRLS() always enables FORCE, preventing bypass by table owners
Connection cleanup Always reset tenant context when releasing connections