14 KiB
Index Strategy & Performance
Overview
Changemaker Lite V2 uses strategic indexing across 33 models to optimize query performance. This document catalogs all indexes, explains their purpose, and provides query optimization guidance.
Total Indexes: 60+ (Prisma: 50+, Drizzle: 10+)
Index Types:
- Unique indexes — Enforce uniqueness constraints (email, slug, token, etc.)
- Foreign key indexes — Optimize JOIN operations (userId, campaignId, locationId, etc.)
- Composite indexes — Multi-column indexes for complex queries
- Spatial indexes — Latitude/longitude for geographic queries
Index Catalog
Auth & Users
User
- Unique:
email— Login lookups (WHERE email = ?)
RefreshToken
- Unique:
token— Refresh endpoint lookups (WHERE token = ?) - Foreign Key:
userId— User deletion cascades
Influence
Campaign
- Unique:
slug— Public campaign page lookups (WHERE slug = ?)
Representative
- Non-unique:
postalCode— Postal code lookups (WHERE postalCode = ?)
CampaignEmail
- Foreign Key:
campaignId— Campaign email stats (JOIN campaign_emails ON campaign_id = ?) - Non-unique:
campaignSlug— Slug-based queries
RepresentativeResponse
- Foreign Key:
campaignId— Campaign response wall (JOIN representative_responses ON campaign_id = ?) - Non-unique:
campaignSlug— Slug-based queries
ResponseUpvote
- Unique:
[responseId, userId]— Prevent duplicate upvotes from logged-in users - Unique:
[responseId, upvotedIp]— Prevent duplicate upvotes from same IP
CustomRecipient
- Foreign Key:
campaignId— Campaign custom recipients (JOIN custom_recipients ON campaign_id = ?)
PostalCodeCache
- Unique:
postalCode— Postal code cache lookups (WHERE postal_code = ?)
Call
- Foreign Key:
campaignId— Campaign call tracking (JOIN calls ON campaign_id = ?)
Map — Locations
Location
- Unique:
locGuid— NAR location GUID lookups - Composite:
[latitude, longitude]— Spatial queries (nearby locations, bounding box searches) - Non-unique:
postalCode— Postal code filtering
Query Optimization:
-- Uses composite index for bounding box queries
SELECT * FROM locations
WHERE latitude BETWEEN ? AND ?
AND longitude BETWEEN ? AND ?;
Address
- Unique:
addrGuid— NAR address GUID lookups - Foreign Key:
locationId— Location addresses (JOIN addresses ON location_id = ?) - Composite:
[locationId, unitNumber]— Unit lookups within building
Query Optimization:
-- Uses composite index for unit-specific queries
SELECT * FROM addresses
WHERE location_id = ? AND unit_number = ?;
LocationHistory
- Foreign Key:
locationId— Location history (JOIN location_history ON location_id = ?) - Foreign Key:
userId— User edit history (JOIN location_history ON user_id = ?) - Non-unique:
createdAt— Temporal queries (recent edits, audit trails)
Map — Shifts & Cuts
Shift
- Foreign Key:
cutId— Cut shifts (JOIN shifts ON cut_id = ?)
ShiftSignup
- Unique:
[shiftId, userEmail]— Prevent duplicate shift signups - Foreign Key:
shiftId— Shift signups (JOIN shift_signups ON shift_id = ?)
Canvassing
CanvassSession
- Foreign Key:
userId— User canvass sessions (JOIN canvass_sessions ON user_id = ?) - Foreign Key:
cutId— Cut canvass sessions (JOIN canvass_sessions ON cut_id = ?) - Foreign Key:
shiftId— Shift canvass sessions (JOIN canvass_sessions ON shift_id = ?)
CanvassVisit
- Foreign Key:
addressId— Address visit history (JOIN canvass_visits ON address_id = ?) - Foreign Key:
userId— User visit history (JOIN canvass_visits ON user_id = ?) - Foreign Key:
shiftId— Shift visits (JOIN canvass_visits ON shift_id = ?) - Foreign Key:
sessionId— Session visits (JOIN canvass_visits ON session_id = ?) - Non-unique:
visitedAt— Temporal queries (recent visits, activity feeds)
TrackingSession
- Unique:
canvassSessionId— One-to-one relationship with CanvassSession - Foreign Key:
userId— User GPS sessions (JOIN tracking_sessions ON user_id = ?) - Non-unique:
isActive— Active session filtering (WHERE is_active = true) - Composite:
[isActive, lastRecordedAt]— Session cleanup queries (abandoned sessions)
Query Optimization:
-- Uses composite index for abandoned session cleanup
SELECT * FROM tracking_sessions
WHERE is_active = true
AND last_recorded_at < NOW() - INTERVAL '12 hours';
TrackPoint
- Composite:
[trackingSessionId, recordedAt]— Temporal GPS queries (session breadcrumb trail) - Non-unique:
recordedAt— Cross-session temporal queries
Email Templates
EmailTemplate
- Unique:
key— Template key lookups (WHERE key = 'campaign-email') - Non-unique:
category— Category filtering (WHERE category = 'INFLUENCE') - Non-unique:
isActive— Active template filtering (WHERE is_active = true)
EmailTemplateVariable
- Unique:
[templateId, key]— Unique variable keys per template - Foreign Key:
templateId— Template variables (JOIN email_template_variables ON template_id = ?)
EmailTemplateVersion
- Unique:
[templateId, versionNumber]— Sequential version numbers per template - Composite:
[templateId, createdAt(sort: Desc)]— Recent version history
Query Optimization:
-- Uses composite index for recent version queries
SELECT * FROM email_template_versions
WHERE template_id = ?
ORDER BY created_at DESC
LIMIT 10;
EmailTemplateTestLog
- Composite:
[templateId, sentAt(sort: Desc)]— Recent test logs
Landing Pages
LandingPage
- Unique:
slug— Public page lookups (WHERE slug = 'about')
Media (Drizzle ORM)
videos
- Unique:
path— File path lookups (WHERE path = '/media/local/videos/file.mp4') - Non-unique:
orientation— Orientation filtering (WHERE orientation = 'landscape') - Non-unique:
producer— Producer filtering (WHERE producer = 'Studio A') - Non-unique:
isValid— Valid video filtering (WHERE is_valid = true) - Non-unique:
directoryType— Directory type filtering (WHERE directory_type = 'studios') - Composite:
[durationSeconds, fileSize, width, height]— Fingerprint matching (duplicate detection) - Composite:
[directoryType, isValid, orientation]— Common filtering pattern
Query Optimization:
-- Uses composite index for common video library queries
SELECT * FROM videos
WHERE directory_type = 'studios'
AND is_valid = true
AND orientation = 'landscape';
jobs
- Composite:
[status, priority, createdAt]— Job queue processing - Composite:
[resourceCategory, status]— Resource-based filtering - Non-unique:
pipelineId— Pipeline job filtering
Query Optimization:
-- Uses composite index for job queue queries
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY priority ASC, created_at ASC
LIMIT 10;
Query Optimization Patterns
1. Use Indexes for WHERE Clauses
// ✅ Uses email unique index
await prisma.user.findUnique({ where: { email: 'user@example.com' } });
// ❌ Full table scan (no index on name)
await prisma.user.findMany({ where: { name: 'John' } });
2. Use Composite Indexes for Multi-Column Filters
// ✅ Uses [latitude, longitude] composite index
await prisma.location.findMany({
where: {
latitude: { gte: 53.5, lte: 53.6 },
longitude: { gte: -113.5, lte: -113.4 },
},
});
// ❌ Less efficient (only uses latitude index)
await prisma.location.findMany({
where: {
latitude: { gte: 53.5, lte: 53.6 },
// longitude filter applied after index scan
},
});
3. Use Foreign Key Indexes for JOINs
// ✅ Uses campaignId foreign key index
await prisma.campaign.findUnique({
where: { id: campaignId },
include: { emails: true }, // JOIN uses index
});
// ❌ N+1 query (loads emails one-by-one)
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
const emails = await prisma.campaignEmail.findMany({ where: { campaignId: campaign.id } });
4. Use Unique Indexes for Deduplication
// ✅ Uses [responseId, userId] unique index
await prisma.responseUpvote.create({
data: { responseId, userId, upvotedIp },
});
// Throws error if user already upvoted (database-level check)
// ❌ Application-level check (race condition)
const existing = await prisma.responseUpvote.findFirst({
where: { responseId, userId },
});
if (existing) throw new Error('Already upvoted');
await prisma.responseUpvote.create({ data: { responseId, userId } });
5. Use Temporal Indexes for Date Filtering
// ✅ Uses createdAt index
await prisma.locationHistory.findMany({
where: {
createdAt: { gte: new Date('2025-01-01') },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
// ❌ Full table scan (no index on field)
await prisma.locationHistory.findMany({
where: {
oldValue: { contains: 'Calgary' }, // No index
},
});
Index Selectivity
Selectivity = Percentage of unique values in indexed column. Higher selectivity = better index performance.
High Selectivity (Good)
- email (User) — 100% unique (1 user per email)
- token (RefreshToken) — 100% unique (1 token per record)
- slug (Campaign, LandingPage) — 100% unique (1 record per slug)
- [responseId, userId] (ResponseUpvote) — High uniqueness (1 upvote per user per response)
Medium Selectivity (Okay)
- postalCode (Location) — ~50% unique (multiple locations per postal code)
- campaignId (CampaignEmail) — ~10% unique (100s of emails per campaign)
- directoryType (videos) — ~11% unique (9 directory types)
Low Selectivity (Poor for filtering, good for covering index)
- isActive (TrackingSession) — ~50% unique (active vs inactive)
- status (Campaign) — ~25% unique (4 statuses: DRAFT, ACTIVE, PAUSED, ARCHIVED)
- role (User) — ~20% unique (5 roles)
Optimization:
- Use low-selectivity indexes as first column in composite index only
- Example:
[isActive, lastRecordedAt]usesisActiveto narrow search, thenlastRecordedAtfor ordering
Index Maintenance
Prisma Indexes (Automatic)
Prisma migrations automatically create indexes defined in schema.prisma:
model Location {
latitude Decimal
longitude Decimal
@@index([latitude, longitude]) // Composite index
}
Drizzle Indexes (Manual in Schema)
Drizzle indexes defined in schema.ts:
export const videos = pgTable('videos', {
directoryType: text('directory_type'),
isValid: boolean('is_valid'),
orientation: text('orientation'),
}, (table) => ({
directoryValidOrientationIdx: index('idx_videos_directory_valid_orientation')
.on(table.directoryType, table.isValid, table.orientation),
}));
Index Size Monitoring
-- Check index sizes
SELECT
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
Unused Index Detection
-- Find indexes with zero scans (unused)
SELECT
schemaname,
tablename,
indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND idx_scan = 0
AND indexrelid NOT IN (
SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')
)
ORDER BY pg_relation_size(indexrelid) DESC;
Performance Considerations
Index Trade-offs
- Pros: Faster SELECT queries, enforces uniqueness, prevents N+1
- Cons: Slower INSERT/UPDATE/DELETE (index must be updated), increased storage
Rule of Thumb:
- Index all foreign keys (JOIN performance)
- Index all unique constraints (data integrity)
- Index columns used in WHERE clauses frequently
- Avoid indexing low-selectivity columns alone
- Avoid indexing large text fields (use full-text search instead)
Query Planning
Use EXPLAIN ANALYZE to verify index usage:
EXPLAIN ANALYZE
SELECT * FROM locations
WHERE latitude BETWEEN 53.5 AND 53.6
AND longitude BETWEEN -113.5 AND -113.4;
-- Output should show "Index Scan using locations_latitude_longitude_idx"
Index Bloat
Over time, indexes can become bloated (unused space). Monitor with:
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
Fix bloat: REINDEX INDEX index_name; (requires table lock)
Common Performance Issues
Issue: Slow campaign email stats query
Query:
SELECT COUNT(*) FROM campaign_emails WHERE campaign_id = ?;
Solution: Already optimized (uses campaignId foreign key index)
Issue: Slow location bounding box queries
Query:
SELECT * FROM locations WHERE latitude > ? AND latitude < ? AND longitude > ? AND longitude < ?;
Solution: Already optimized (uses [latitude, longitude] composite index)
Issue: Slow active session cleanup
Query:
SELECT * FROM tracking_sessions WHERE is_active = true AND last_recorded_at < ?;
Solution: Already optimized (uses [isActive, lastRecordedAt] composite index)
Issue: Slow template version history
Query:
SELECT * FROM email_template_versions WHERE template_id = ? ORDER BY created_at DESC LIMIT 10;
Solution: Already optimized (uses [templateId, createdAt(sort: Desc)] composite index)
Related Documentation
- Database Overview — Complete ER diagram
- Schema Reference — All model fields
- Migration Workflow — Creating indexes in migrations
- Common Queries — Query examples with index usage
- PostgreSQL Index Documentation