Cómo construimos la base de datos de un blog AI-first en una tarde
Supabase, Postgres, RLS y multi-tenant por subdominio. El esquema entero cabe en cinco migraciones — y aquí están las decisiones que importan.
El primer commit de Agentikas Blog no fue una página, ni un componente, ni un endpoint. Fue una migración SQL.
Si vas a construir una plataforma multi-tenant donde cualquiera puede crear su blog, y donde cada blog tiene que ser invocable por agentes de IA desde el primer post, la base de datos no es un detalle de implementación — es la decisión que define el resto. Una tarde de tres horas con Postgres, Supabase y un editor abierto.
El modelo: blogs y posts, con un giro
El modelo central es trivial:
create table blogs (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users (id) on delete cascade,
slug text not null unique,
name text not null,
brand_config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table blog_posts (
id uuid primary key default gen_random_uuid(),
blog_id uuid not null references blogs (id) on delete cascade,
slug text not null,
title text not null,
body text not null default '',
status post_status not null default 'draft',
published_at timestamptz,
unique (blog_id, slug)
);
El "giro" es el slug del blog. No es un campo cosmético. Es el identificador de tenant. Cuando alguien visita maria.blog.agentikas.ai/post-uno, el middleware lo único que hace es:
- Extraer
mariadel Host header - Buscar
blogsdondeslug = 'maria' - Pasar el
blog_ida las queries del resto del request
No hay tabla de tenants separada, ni middleware de autenticación pesado, ni servicio dedicado. Postgres y Supabase resuelven el aislamiento.
Full-text search en una columna generada
Una decisión que parece menor pero ahorra muchas horas: la búsqueda full-text como columna generada y almacenada, no calculada en cada query.
alter table blog_posts add column fts tsvector
generated always as (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(subtitle, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'C')
) stored;
create index blog_posts_fts_idx on blog_posts using gin (fts);
El setweight hace que un match en el título pese más que un match en el cuerpo. Es la diferencia entre "search funciona regular" y "search devuelve lo que esperas". Y como la columna es stored, Postgres la mantiene actualizada automáticamente con cada UPDATE — cero código de aplicación.
RLS: la seguridad vive en la base de datos, no en los handlers
Durante los primeros sprints todas las API routes usaban el service role key de Supabase. Cómodo: un solo cliente, escribe a cualquier tabla, ignora RLS. La validación de "¿puede este usuario tocar este post?" la hacíamos a mano en cada handler.
Hasta que en al menos dos rutas se nos olvidó la validación. No explotó — nadie editó el post equivocado. Pero podrían haberlo hecho. La seguridad vivía "en la cabeza del backend dev", no en la base de datos.
La regla nueva, después del refactor:
- User client + RLS en todo endpoint que actúa en nombre de un usuario autenticado. La DB decide.
- Service client solo para trabajos de background (cron, webhooks) y operaciones cross-tenant legítimas (el mirror público que lista posts de todos los blogs).
Las policies se ven así:
create policy "anyone reads published posts"
on blog_posts for select
using (status = 'published');
create policy "owners do anything with their posts"
on blog_posts for all
using (
exists (
select 1 from blogs
where blogs.id = blog_posts.blog_id
and blogs.owner_id = auth.uid()
)
);
Si tu seguridad vive en if statements, muévela a la DB. El código muta; las policies se auditan.
i18n nativa el día uno
Cada post tiene locale y canonical_id. La traducción no es un campo extra en el mismo registro — es una fila propia con su slug, su body, su meta description en el idioma destino. Las hermanas de la traducción comparten canonical_id apuntando a la fila raíz.
alter table blog_posts
add column locale text not null default 'es',
add column canonical_id uuid references blog_posts(id);
drop index if exists blog_posts_blog_id_slug_key;
create unique index blog_posts_blog_locale_slug_idx
on blog_posts (blog_id, locale, slug);
Esto permite que /es/blog/webmcp-protocolo y /en/blog/webmcp-protocol coexistan en el mismo blog sin colisionar. Y que un agente que pide el post en en reciba en — no un fallback silencioso a otro idioma, que es lo que hace que las respuestas de la IA queden chapuzas.
i18n añadida en el mes 3 te come dos semanas de migraciones. Puesta en el schema el día uno, no cuesta nada.
El resto vino solo
El resto del esquema cayó por inercia: post_likes, post_comments, blog_follows, post_translations, contributions. Cada uno una migración corta, cada uno con sus policies, cada uno generado tras tener un caso de uso real — nunca antes.
Hoy son 19 migraciones acumuladas, ninguna de más de 80 líneas. Y todas viven en supabase/migrations/, versionadas en git, ejecutables localmente con la CLI de Supabase.
La lección de fondo no es sobre Postgres. Es sobre el orden de las cosas: esquema primero, código después. Cuando el esquema es correcto — RLS estricta, i18n nativa, full-text generated — el resto del backend es un thin layer encima. Cuando el esquema es regular, te pasas seis meses parchando.
Agentikas es open source. El esquema completo está en github.com/agentikas/agentikas-blog/tree/main/supabase/migrations. La política de RLS, la columna fts y el modelo de traducciones son tres ficheros — léelos, fórkalos, mejóralos si ves cómo.
Comentarios
Cargando comentarios…
Inicia sesión en tu dashboard para participar.