03 β€” Database Topology & Data Isolation

← Index

How tenant data is physically and logically separated, how databases are created and migrated, and the connection/queue concerns that matter when scaling toward instance-per-tenant.

Three-tier topology

TierHoldsCardinality
Core DB (CORE_DB_*)The control plane: tenants, regions, licenses, deployments, domains, branding.One, shared by all.
Region shared DBBusiness rows for shared tenants, every row scoped by tenant_id. Name comes from Region.shared_database_name (e.g. ip4cms_shared).One per region.
Dedicated tenant DBOne tenant's entire dataset, physically separate. Named tenant_<uuid> (uuid with -β†’_).One per dedicated tenant.

The switch between the last two is Tenant.has_dedicated_database (src/core-api/src/database/schema.ts:178 β€” "true = own DB (tenant_xxx), false = shared DB on region server", default true).

This gives two grades of data isolation:

In production the Postgres engine is Azure Cosmos DB for PostgreSQL (Citus) β€” a distributed Postgres. Citus is designed for distributed shared tables (distribute by tenant_id), which fits the shared-DB model well. It is not ideal for hundreds of CREATE DATABASE tenant_x databases on one coordinator; for residency-driven dedicated tenants, per-region flexible-server Postgres is the better substrate than DB-per-tenant on a single Citus coordinator.

How the tenant-api resolves a database

The data plane reads connection settings from env (src/tenant-api/src/config/index.ts):

getDatabase(tenantId) (src/tenant-api/src/database/connection.ts) returns a Drizzle client for a tenant, caching one connection pool per tenant in a Map (connection.ts:16). The driver is postgres.js.

Connection-count implication. In the shared model a single tenant-api process holds one pool (up to 10) per tenant it serves, and the prod process runs in pm2 cluster mode (multiple workers), so live usage β‰ˆ workers Γ— active_tenants Γ— 10. In an instance-per-tenant model each instance holds ~10 (tenant) + 8 (core) β‰ˆ 18 connections, so N tenants β‰ˆ N Γ— 18 connections against the cluster. Citus coordinators cap max_connections in the hundreds–low thousands and amplify it with coordinatorβ†’worker fan-out β€” so a connection pooler (PgBouncer, transaction mode) is required to scale instance-per-tenant. Azure Cosmos for PostgreSQL ships a managed PgBouncer on port 6432 (the app currently connects direct on 5432). Transaction-mode pooling requires disabling postgres.js prepared statements (prepare: false), and per-instance pools should be shrunk (2–4) for dedicated tenants.

Migrations β€” owned by the data plane

Drizzle migrations live at src/tenant-api/drizzle/ as hand-authored, idempotent SQL (every statement uses IF NOT EXISTS / IF EXISTS, partial unique indexes with WHERE, etc.). They are applied by the tenant-api's own migrator:

Operational note: the migrator applies entries by a when ordering recorded in the Drizzle journal; a migration whose when is earlier than the last-applied is silently skipped β€” so new migrations must carry a strictly-later when.

Queue isolation {#queue-isolation}

BullMQ queues live in Redis, which is shared infrastructure. Queues are keyed by name, so isolation between tenant-api instances depends on a per-instance key prefix:

⚠️ This is the open gap from 02: the container_per_tenant env built by provisionDeployment omits REDIS_PREFIX. It must be added before running more than one dedicated instance.

The same prefixing applies to the Redis-backed RedisLockService (used by document numbering and settlement matching).

What stays shared vs per-tenant

ConcernShared singletonPer-tenant (dedicated)
Core DB, object storage, core-apiβœ… always sharedβ€”
Business dataregion shared DB (tenant_id-scoped)tenant_<uuid> DB
Compute (tenant-api)pooled instancededicated instance
BullMQ queuesshared Redis, one namespaceshared Redis, distinct REDIS_PREFIX
For strict residencyβ€”in-region DB host + in-region cluster (seam to wire)

Key source paths: src/tenant-api/src/database/connection.ts, src/tenant-api/src/database/init.ts, src/tenant-api/src/config/index.ts, src/tenant-api/drizzle/, src/core-api/src/database/schema.ts.

Next: 04 β€” Feature isolation: licensing & RBAC β†’