Comprehensive Rules for designing, creating, and maintaining high-performance, multi-tenant database, search, and SEO indexes in TypeScript/NestJS back-end projects.
You're building a multi-tenant application. Your database queries are slowing down. Your search is returning stale results. Your sitemap isn't updating. Sound familiar?
Most developers throw indexes at problems reactively — creating them when queries slow down, dropping them when space runs low, and hoping for the best with multi-tenant isolation. This reactive approach creates technical debt that compounds over time.
Here's what happens when indexing isn't treated as a first-class concern:
Performance Degradation: Without proper tenant separation in indexes, one tenant's heavy queries can starve others. A single tenant uploading 100K records can bring down response times for everyone else.
Security Vulnerabilities: Shared indexes without proper isolation leak data between tenants. Row-level security becomes meaningless when indexes don't enforce tenant boundaries.
Operational Overhead: Manual index management doesn't scale. Creating indexes in production, forgetting to monitor performance, and discovering bloated indexes during outages.
Search Inconsistency: Elasticsearch indexes that don't match your PostgreSQL schema. Stale search results because bulk updates happen field-by-field instead of using proper reindexing.
These Cursor Rules transform your indexing approach from reactive firefighting to proactive architecture. You'll build indexes that are secure, performant, and maintainable from day one.
Multi-Tenant Safe by Default: Every index includes tenant isolation through composite keys, RLS, and proper routing. No more data leaks between tenants.
Performance-First Design: Covering indexes that keep your working set in memory. Partial indexes for soft-deleted records. Proper partitioning for time-series data.
Automated Operations: CI/CD pipelines that handle migrations, sitemap generation, and index maintenance. No more manual production changes.
Observable and Measurable: Built-in monitoring with Grafana dashboards, structured logging, and performance regression detection.
// Slow query discovered in production
SELECT * FROM orders WHERE customer_id = ? AND created_at > ?;
// Quick fix: throw an index at it
CREATE INDEX idx_orders_customer_created ON orders(customer_id, created_at);
// Result: Works for now, but no tenant isolation, no monitoring, no lifecycle management
// Schema-first approach with tenant safety
export const OrderIndexSchema = z.object({
tenant_id: z.string().uuid(),
customer_id: z.string().uuid(),
created_at: z.date(),
status: z.enum(['pending', 'completed', 'cancelled'])
});
// Migration with proper naming and coverage
CREATE INDEX CONCURRENTLY idx_orders_tenant_customer_created
ON orders(tenant_id, customer_id, created_at)
INCLUDE (status, total_amount)
WHERE deleted_at IS NULL;
// Before: Shared indexes with filter-based isolation
GET /orders/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "tenant_id": "tenant-123" } }
]
}
}
}
// After: Routing-based isolation with proper templates
@Injectable()
export class OrderSearchService {
async searchOrders(query: string, tenantId: string) {
return this.elasticsearchClient.search({
index: 'orders_current',
routing: tenantId, // Physical separation
body: {
query: {
bool: {
must: [
{ match: { description: query } },
{ term: { tenant_id: tenantId } } // Defense in depth
]
}
}
}
});
}
}
// Built-in performance tracking
@Injectable()
export class DatabaseService {
async executeQuery(sql: string, params: any[], tenantId: string) {
const start = Date.now();
try {
const result = await this.pool.query(sql, params);
this.logger.info('Query executed', {
op: 'query',
entity: this.extractTableName(sql),
tenantId,
durationMs: Date.now() - start,
rowCount: result.rowCount
});
return result;
} catch (error) {
this.alertService.fireAlert('query_failed', { sql, tenantId, error });
throw error;
}
}
}
# Install dependencies
npm install @nestjs/elasticsearch @nestjs/bull zod pg typeorm
# Update tsconfig.json
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true
}
}
// tenant-context.provider.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
private tenantId: string;
setTenantId(tenantId: string) {
this.tenantId = tenantId;
}
getTenantId(): string {
if (!this.tenantId) {
throw new Error('Tenant context not initialized');
}
return this.tenantId;
}
}
// tenant.guard.ts
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private tenantContext: TenantContext) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.headers['x-tenant-id'];
if (!tenantId) {
throw new UnauthorizedException('Tenant ID required');
}
this.tenantContext.setTenantId(tenantId);
return true;
}
}
// index-management.service.ts
@Injectable()
export class IndexManagementService {
constructor(
private dataSource: DataSource,
private elasticsearchClient: ElasticsearchService,
private tenantContext: TenantContext
) {}
async createTenantSafeIndex(
tableName: string,
columns: string[],
options: IndexOptions = {}
) {
const tenantId = this.tenantContext.getTenantId();
const indexName = `idx_${tableName}_${columns.join('_')}_tenant`;
const sql = `
CREATE INDEX CONCURRENTLY ${indexName}
ON ${tableName}(tenant_id, ${columns.join(', ')})
${options.include ? `INCLUDE (${options.include.join(', ')})` : ''}
${options.where ? `WHERE ${options.where}` : ''}
`;
const start = Date.now();
await this.dataSource.query(sql);
this.logger.info('Index created', {
op: 'create_index',
entity: tableName,
tenantId,
indexName,
durationMs: Date.now() - start
});
}
}
// elasticsearch-setup.service.ts
@Injectable()
export class ElasticsearchSetupService implements OnModuleInit {
async onModuleInit() {
await this.createIndexTemplate();
}
private async createIndexTemplate() {
await this.elasticsearchClient.indices.putTemplate({
name: 'orders_template',
body: {
index_patterns: ['orders_v*'],
settings: {
number_of_shards: 3,
number_of_replicas: 1,
'index.routing.allocation.require.storage_type': 'ssd'
},
mappings: {
properties: {
tenant_id: { type: 'keyword' },
customer_id: { type: 'keyword' },
description: {
type: 'text',
analyzer: 'standard'
},
created_at: { type: 'date' },
total_amount: {
type: 'scaled_float',
scaling_factor: 100
}
}
},
aliases: {
'orders_current': {}
}
}
});
}
}
// monitoring.service.ts
@Injectable()
export class MonitoringService {
constructor(private logger: Logger) {}
async checkIndexHealth() {
// PostgreSQL index analysis
const missingIndexes = await this.dataSource.query(`
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
AND n_distinct > 100
AND correlation < 0.1
`);
if (missingIndexes.length > 0) {
this.logger.warn('Potential missing indexes detected', {
candidates: missingIndexes
});
}
// Elasticsearch cluster health
const clusterHealth = await this.elasticsearchClient.cluster.health();
if (clusterHealth.body.status !== 'green') {
this.logger.error('Elasticsearch cluster unhealthy', {
status: clusterHealth.body.status
});
}
}
}
Query Response Times: Properly designed covering indexes reduce typical query times from 200ms to under 20ms. Tenant-specific routing in Elasticsearch eliminates cross-tenant interference.
Resource Utilization: Partial indexes for soft-deleted records reduce index size by 30-40% in typical applications. Memory usage stays predictable with proper working set management.
Operational Efficiency: Automated index lifecycle management eliminates manual production changes. Teams report 80% reduction in database-related incidents.
Your database becomes a competitive advantage instead of a bottleneck. Your search results stay fresh and relevant. Your application scales predictably as you add tenants and data volume grows.
These rules don't just prevent problems — they create the foundation for applications that perform well at scale while maintaining security and operational simplicity.
You are an expert in PostgreSQL, Elasticsearch, XML Sitemaps, NestJS, and TypeScript.
Key Principles
- Treat every index as a first-class citizen: design, name, test, monitor, and retire it deliberately.
- Multi-tenant safety is non-negotiable: always separate tenants through composite keys, RLS, and encrypted columns.
- Measure before you optimize: create or drop indexes only after inspecting pg_stat_statements / Elasticsearch slow logs.
- Keep the working set in memory: prefer covering indexes or field collapsing to avoid redundant fetches.
- Automate everything: migrations, sitemap pushes, index templates, and dashboard alerts must run via CI/CD.
TypeScript Rules
- Use strictNullChecks and noImplicitAny in tsconfig; never disable them in subfolders.
- Export a single public API per file; suffix private helpers with ".internal".
- Declare DB columns and ES mappings in typed schemas; infer() the TypeScript types from the schema instead of duplicating.
- Prefer async/await over raw Promise chains; keep handler depth ≤ 2.
- Wrap dangerous native queries in `sql.tag` template literals with linting for identifier injection.
SQL / PostgreSQL Rules
- Index naming: idx_<table>_<column(s)>_[tenant] (e.g., idx_orders_created_at_tenant).
- Always include tenant_id as the leading column of every user-visible index.
- Use partial indexes for soft-deleted records (`WHERE deleted_at IS NULL`).
- Add INCLUDE columns to create covering indexes instead of widening the key.
- Never create more than one multi-column index that is a left-prefix of another.
- Re-index via `CONCURRENTLY`; schedule during low-traffic windows.
Elasticsearch Rules
- Use index templates with versioned aliases: myindex_v1 → myindex_current.
- Define multi-tenant routing keys: `_routing = <tenantId>`; never rely on filters alone.
- Store only unanalyzed `keyword` for identifiers; use `text` with `standard` analyzer for free text.
- Prefer `doc_values":"false"` on high-cardinality text fields to reduce heap.
- Paginate large scrolls with `search_after`, not `from/size`.
Error Handling and Validation
- Validate payloads with Zod schemas before hitting DB/ES.
- Log index operations with structured logs `{ op, entity, tenantId, durationMs }`.
- Abort migrations on any DDL statement error; wrap in transaction where possible (PostgreSQL) or use two-phase alias switch (Elasticsearch).
- Fire PagerDuty alert if index creation exceeds 2× baseline median or fails.
NestJS Rules
- Place all data-access logic in `*/infra/*` providers; expose only typed repository methods.
- Decorate index tasks with `@Processor('indexing')` (BullMQ) and limit concurrency per tenant.
- Inject a global `TenantContext` via request-scope provider; repositories must read routing key from it.
- Use `nestjs-elasticsearch` client with a circuit-breaker (opossum) wrapper.
- Expose `/sitemap.xml` via a dedicated controller; regenerate on content mutation events and ping GSC.
Testing
- Create `dataset.seed.ts` that populates 3 tenants with 10K mixed records.
- Benchmark critical queries with `pg-benchmarks` and ES with `rally`; assert P95 latency < 50 ms.
- Add jest e2e tests that spin up PostgreSQL & ES containers; verify tenant isolation.
- Use `EXPLAIN (ANALYZE, BUFFERS)` snapshots and fail CI if total cost regresses > 10 %.
Performance
- Enable `pg_partman` for time-series tables; keep max 6 partitions hot.
- Pin index directories to NVMe volumes; monitor I/O wait < 5 %.
- For ES, set `index.translog.flush_threshold_size = 512mb` and force-merge to 1 segment before promotion.
- Cache read-heavy endpoints with `@CacheTTL(30)` and a Redis backend.
Security
- Activate PostgreSQL RLS: `USING (tenant_id = current_setting('app.tenant_id')::uuid)`.
- Encrypt PII columns with pgcrypto; never include encrypted blobs in b-tree indexes, use HASH indexes.
- Restrict ES to VPC-only access; sign requests with AWS SigV4 if hosted.
- Sanitize sitemap URLs, enforce HTTPS, and add X-Robots-Tag headers for non-indexable pages.
DevOps / Observability
- Ship pg_stat_statements and ES metrics to Grafana; dashboards must include "Top Missing Index Candidates".
- Rotate ES indices nightly using ILM policies: hot (2 days) → warm (30 days) → cold (180 days) → delete.
- Apply schema migrations via `npm run migrate` which runs `typeorm migration:run` and `es-index-sync` sequentially.
Common Pitfalls & Guardrails
- DO NOT add an index merely to fix a single slow admin report; analyze global workload first.
- DO NOT ignore `dead_tuples`; auto-vacuum may skip bloated indexes.
- DO NOT bulk-update ES documents field-by-field; re-index from source using the reindex API.
- DO create DROP migrations for unused indexes; keep < 3× tables count.