lead_events tiene particionado por rango pero no tiene RLS activado ni política. Cualquier usuario autenticado puede leer eventos de otros tenants con un query directo. En Supabase, el cliente PostgREST expone directamente cualquier tabla sin RLS.-- Activar RLS en la tabla particionada (aplica a todas las particiones) ALTER TABLE lead_events ENABLE ROW LEVEL SECURITY; ALTER TABLE lead_events FORCE ROW LEVEL SECURITY; -- Política de aislamiento por tenant -- Patrón correcto: (SELECT auth.uid()) evaluado una vez, no por fila CREATE POLICY lead_events_org_isolation ON lead_events FOR ALL USING ( lead_id IN ( SELECT id FROM leads WHERE org_id = (SELECT org_id FROM users WHERE id = (SELECT auth.uid())) ) ); -- Índice en lead_id para que la política sea O(log n) CREATE INDEX idx_lead_events_lead_id ON lead_events(lead_id);
sequences usa IN (SELECT …) sin envolver en (SELECT …). PostgreSQL evalúa la subquery para cada fila, causando un full scan en users por cada fila de sequences evaluada. En tablas con millones de filas esto destruye el rendimiento.-- Subquery evaluada POR CADA FILA CREATE POLICY sequences_org_isolation ON sequences FOR ALL USING ( org_id IN ( SELECT org_id FROM users WHERE id = auth.uid() ) );
-- (SELECT …) evalúa UNA VEZ por query CREATE POLICY sequences_org_isolation ON sequences FOR ALL USING ( org_id = (SELECT org_id FROM users WHERE id = (SELECT auth.uid())) );
leads_org_isolation (ya la usa pero en secuencias no).LIKE '%term%' impide usar índices B-tree. Con 8M filas sin FTS, PostgreSQL hace Seq Scan completo. Además OFFSET $3 en paginación descarta filas en memoria: para la página 500 lee 10.000 filas y tira 9.980. Combinados provocan timeouts a partir de ~1M filas.-- 1. Columna de búsqueda generada (no ocupa espacio duplicado) ALTER TABLE leads ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( to_tsvector('spanish', coalesce(name, '') || ' ' || coalesce(email, '')) ) STORED; -- 2. Índice GIN para búsqueda full-text O(log n) CREATE INDEX idx_leads_search_gin ON leads USING GIN(search_vector); -- 3. Query con cursor pagination (sin OFFSET) SELECT id, name, email, score, status, created_at FROM leads WHERE org_id = $1 AND search_vector @@ plainto_tsquery('spanish', $2) AND deleted_at IS NULL AND id > $3 -- cursor (último id de la página anterior) ORDER BY id LIMIT 20;
users.org_id → organizations.id no tiene índice. Toda query que joinee estas tablas (incluyendo las políticas RLS) hace Seq Scan en users. Regla: siempre indexar FKs, sin excepciones.-- Índices en claves foráneas (los que faltan) CREATE INDEX idx_users_org_id ON users(org_id); CREATE INDEX idx_leads_org_id ON leads(org_id); CREATE INDEX idx_sequences_org_id ON sequences(org_id); CREATE INDEX idx_enrollments_lead_id ON enrollments(lead_id); CREATE INDEX idx_enrollments_seq_id ON enrollments(sequence_id);
TIMESTAMP almacena sin zona horaria. En producción multi-región o con cambios de DST los datos quedan inconsistentes. Usar siempre TIMESTAMPTZ (alias timestamp with time zone).-- Migración para corregir tipos de timestamp (requiere mantenimiento breve) ALTER TABLE organizations ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC'; ALTER TABLE users ALTER COLUMN last_login TYPE timestamptz USING last_login AT TIME ZONE 'UTC'; -- Repetir para: leads.deleted_at, leads.created_at, leads.updated_at, -- sequences.created_at, enrollments.enrolled_at, enrollments.completed_at, -- lead_events.created_at (partición)
SERIAL usa int4 (máx 2.147M). Con 8M leads actuales y crecimiento previsto, enrollments y lead_events pueden llegar al límite. Usar BIGINT GENERATED ALWAYS AS IDENTITY o UUIDv7 para IDs distribuibles.CREATE TABLE leads ( id SERIAL PRIMARY KEY, -- int (4 bytes, max 2.1B) ...
CREATE TABLE leads ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- bigint (8 bytes, max 9.2 × 10¹⁸) ...
(org_id, deleted_at IS NULL, created_at >= …) y agrupa por status. Sin un índice compuesto hace Seq Scan en 8M filas. El orden del índice importa: igualdad primero, rango después.-- Índice compuesto: igualdad (org_id) → rango (created_at) → columnas cubiertas CREATE INDEX idx_leads_org_active_created ON leads(org_id, created_at DESC) INCLUDE(status, score) WHERE deleted_at IS NULL; -- índice parcial: excluye borrados -- EXPLAIN ANALYZE antes/después (ejecutar en staging con datos reales): EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT org_id, status, COUNT(*), AVG(score) FROM leads WHERE org_id = '550e8400-e29b-41d4-a716-446655440000' AND deleted_at IS NULL AND created_at >= NOW() - INTERVAL '30 days' GROUP BY org_id, status;
PARTITION BY RANGE (created_at) pero no hay particiones definidas. PostgreSQL lanza ERROR: no partition of relation "lead_events" en cualquier INSERT. Además faltan índices en las particiones hijas.-- Crear particiones mensuales (adaptar al rango histórico real) CREATE TABLE lead_events_2026_05 PARTITION OF lead_events FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE lead_events_2026_06 PARTITION OF lead_events FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); CREATE TABLE lead_events_2026_07 PARTITION OF lead_events FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); -- Índice en cada partición (o globalmente si PG ≥ 11) CREATE INDEX ON lead_events(lead_id, created_at DESC); -- Automatizar con pg_partman o función cron en Supabase Edge
VARCHAR(255) y TEXT. Usar TEXT simplifica el esquema y evita errores silenciosos de truncado. Añadir CHECK (length(email) < 320) si se quiere validar.ON DELETE CASCADE o ON DELETE RESTRICT según la lógica de negocio.ALTER TABLE users DROP CONSTRAINT users_org_id_fkey, ADD CONSTRAINT users_org_id_fkey FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE CASCADE; -- o RESTRICT según política -- Mismo patrón para leads.org_id, sequences.org_id
enrollments.lead_id y enrollments.sequence_id son enteros simples sin restricción FOREIGN KEY. Se pueden insertar enrollments huérfanos. Añadir las FK garantiza integridad referencial a nivel de base de datos.ALTER TABLE enrollments ADD CONSTRAINT enrollments_lead_id_fkey FOREIGN KEY (lead_id) REFERENCES leads(id) ON DELETE CASCADE, ADD CONSTRAINT enrollments_sequence_id_fkey FOREIGN KEY (sequence_id) REFERENCES sequences(id) ON DELETE CASCADE;