@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.
Installation
Section titled “Installation”npm install @stratum-hq/db-adapters pgRaw PostgreSQL adapter
Section titled “Raw PostgreSQL adapter”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 AsyncLocalStorageconst tenantPool = createTenantPool(pool, () => getTenantContext().tenant_id);const result = await tenantPool.query("SELECT * FROM orders");
// Option 2: Manual adapter usageconst adapter = new RawAdapter(pool);const result = await adapter.query("tenant-id", "SELECT * FROM orders");How it works
Section titled “How it works”The adapter wraps every query in a transaction that:
- Calls
BEGIN - Sets
app.current_tenant_idvia parameterizedset_config(), fully SQL-injection-safe - Executes your query (automatically filtered by RLS policies)
- Calls
COMMIT - Resets the tenant context and releases the connection
Prisma adapter
Section titled “Prisma adapter”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 tenantconst orders = await tenantPrisma.order.findMany();Supported Prisma versions
Section titled “Supported Prisma versions”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.
Schema-per-tenant and database-per-tenant
Section titled “Schema-per-tenant and database-per-tenant”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.
RLS management
Section titled “RLS management”Enable RLS on a table
Section titled “Enable RLS on a table”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.
Disable RLS
Section titled “Disable RLS”import { dropPolicy, disableRLS } from "@stratum-hq/db-adapters";
await dropPolicy(client, "orders");await disableRLS(client, "orders");Check RLS status
Section titled “Check RLS status”import { isRLSEnabled } from "@stratum-hq/db-adapters";
const enabled = await isRLSEnabled(client, "orders"); // true/falseMigration helpers
Section titled “Migration helpers”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 NULLawait enableRLS(client, "orders"); // ENABLE + FORCE ROW LEVEL SECURITYawait createIsolationPolicy(client, "orders"); // CREATE POLICY tenant_isolationSession context
Section titled “Session context”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 scopesetTenantContext 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.
Drizzle adapter
Section titled “Drizzle adapter”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 tenantconst result = await tenantDb.execute(db.select().from(orders));Sequelize adapter
Section titled “Sequelize adapter”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 transactionconst tenantSequelize = sequelizeWithTenantScope( sequelize, () => getTenantContext().tenant_id, pool,);
const orders = await tenantSequelize.query("SELECT * FROM orders");
// Option 2: Manual adapter usageconst adapter = new SequelizeAdapter(pool);const result = await adapter.executeWithTenantContext("tenant-id", async (client) => { return client.query("SELECT * FROM orders");});How it works
Section titled “How it works”The wrapper scopes query() only. It wraps every query() call in a Sequelize transaction that:
- Calls
sequelize.transaction() - Sets
app.current_tenant_idvia parameterizedset_config()inside the transaction - Executes your query on the same connection (guaranteed by the transaction)
- 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.
PGlite adapter
Section titled “PGlite adapter”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/pgliteimport { 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. |
Limits
Section titled “Limits”- One connection. A client from
connect()holds the only connection untilrelease(). 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. UsecreateRestrictedPoolwhen a test must prove isolation. - Only part of
pg.Pool. The pool supportsquery,connect,endandon. Callbacks, cursors,totalCountand the other pool counters do not exist. - PGlite
^0.4.2is the supported version.
Security
Section titled “Security”| 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 |