posts, borradores, y un árbol de comentarios que tardó más que los posts

23 de junio de 2026 meta devlogpostgres

un servicio que solo sabe de posts

La misma división que auth: blog es su propio servicio en Go, no sabe qué es una contraseña, no sabe qué es una sesión. Recibe una petición con un JWT ya verificado adjunto (blog comprueba la firma él mismo contra las claves publicadas de auth, sin necesidad de llamar de vuelta a auth por la red) y se encarga de posts, comentarios, likes, y reportes. Todo en este post vive en una base de datos Postgres llamada blog_db, completamente separada de la auth_db donde viven las cuentas.

slugs, y no confiar en que el título haga uno

Cada post necesita un slug para la URL, y mi primera idea fue simplemente poner el título en minúsculas, cambiar espacios por guiones, listo:

// first idea: no collision handling at all
func slugFromTitle(title string) string {
	return strings.ReplaceAll(strings.ToLower(title), " ", "-")
}

Excepto que los títulos chocan entre sí. Dos posts con un título lo bastante parecido querrían el mismo slug, y el segundo o fallaría directamente o sobrescribiría algo en silencio. Así que la generación de slugs comprueba la base de datos y añade un número hasta encontrar uno libre:

func (s *Server) uniqueSlugFromTitle(ctx context.Context, title string) (string, error) {
	base := slug.Generate(title)
	if base == "" {
		base = "post"
	}

	candidate := base
	for n := 2; ; n++ {
		exists, err := s.Queries.SlugExists(ctx, candidate)
		if err != nil {
			return "", err
		}
		if !exists {
			return candidate, nil
		}
		candidate = fmt.Sprintf("%s-%d", base, n)
	}
}

También puedes simplemente pasarle tu propio slug al crear si quieres, algo que uso a veces cuando el generado sale más feo de lo que me gustaría.

borrador, publicado, archivado

Un post está en uno de tres estados en la base de datos: draft, published, o archived. Lo que no esperaba de entrada es cuánta lógica cuelga de esa única columna. Solo puedes archivar algo que realmente estuvo publicado (archivar un borrador produciría un post accesible en su propia URL sin ninguna fecha de publicación, lo cual no tiene ningún sentido), y volver a modo borrador borra cualquier programación de publicación pendiente para que no se escape más tarde cuando no estaba mirando.

La parte de la programación es de lo que más orgulloso estoy. Puedes poner un post en draft con un timestamp de publish_at en el futuro, y un barrido en segundo plano lo pasa a publicado en cuanto esa hora llega de verdad:

// D2 sweep statement 1: flips a draft to published once its schedule has
// passed, stamping published_at with the scheduled time (not now()) and
// clearing publish_at so it never fires again.
func (q *Queries) PublishScheduledPosts(ctx context.Context) error {
	_, err := q.db.Exec(ctx, publishScheduledPosts)
	return err
}
UPDATE posts SET status = 'published', published_at = publish_at, publish_at = NULL
WHERE status = 'draft' AND publish_at <= now()

Esa sentencia corre cada 30 segundos desde una goroutine que solo hace tick hasta que el proceso se apaga. Es completamente idempotente, una fila que ya no cumple la cláusula WHERE simplemente no se toca, así que es segura de llamar tan seguido como quieras, desde donde sea, incluso un test. Toda esta serie de devlog, de hecho, salió a través de exactamente este mecanismo: escrita y con fecha adelantada de antemano, dejada para que el barrido la recogiera cuando le tocara.

los comentarios son un árbol disfrazado de tabla

Los comentarios necesitaban soportar respuestas, así que un comentario puede apuntar a un comentario padre. Postgres no necesita nada exótico para eso, solo una autorreferencia nullable:

CREATE TABLE comments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
    author_id UUID NOT NULL,
    author_username TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    edited_at TIMESTAMPTZ,
    deleted_at TIMESTAMPTZ
);

El propio API, sin embargo, no devuelve un árbol anidado, devuelve una lista plana ordenada por fecha de creación, cada fila con su propio parent_comment_id:

comments, err := s.Queries.GetCommentsForPost(ctx, post.ID)

El frontend es el que convierte esa lista plana en el hilo anidado que realmente ves. Al principio se sintió un poco raro, como si me estuviera saltando un paso en algún lado, pero en realidad es la forma más flexible: ordenar, paginar, y moderar se quedan como simples queries contra una sola tabla plana, y construir un árbol a partir de una lista plana con punteros al padre es algo completamente mecánico una vez que ya tienes los datos en la mano.

Borrar un comentario tampoco elimina la fila. Pone deleted_at y el API vacía el contenido, pero mantiene la fila viva, porque si no borrar un comentario con tres respuestas debajo o bien haría desaparecer en cascada toda la conversación o dejaría respuestas huérfanas apuntando a nada. Un marcador de "[eliminado]" que mantiene su sitio en el hilo es bastante menos confuso que cualquiera de las dos opciones.

likes y reportes, mantenidos deliberadamente simples

Los likes son una tabla de unión directa, una fila por usuario por comentario, el conteo es un group-by. Los reportes fueron el único sitio donde tuve que pensar un poco en el abuso: nada impide que alguien reporte el mismo comentario cinco veces, salvo un índice único que solo aplica mientras un reporte sigue abierto:

CREATE UNIQUE INDEX reports_open_unique_idx ON reports (comment_id, reporter_id) WHERE resolved_at IS NULL;

Un índice único parcial. Un reporte abierto por persona por comentario, pero en cuanto un moderador lo resuelve, esa misma persona puede reportarlo otra vez si de verdad vuelve a pasar más adelante. Que Postgres haga eso en una sola línea todavía me parece un pequeño truco de magia.

migraciones, o perder el miedo a alterar una tabla

Cada una de estas tablas salió de su propia migración numerada, ejecutada con goose:

-- +goose Up
CREATE TABLE posts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title TEXT NOT NULL,
    slug TEXT NOT NULL,
    content TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'draft',
    published_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED
);

-- +goose Down
DROP TABLE posts;

Antes de este proyecto, lo más cerca que había estado de una migración de verdad era el manage.py migrate de Django, que hace una cantidad enorme del razonamiento por ti. Escribir el up y el down yo mismo, a mano, para cada cambio, me hizo leer de verdad qué le estaba haciendo al esquema en vez de confiar en que una herramienta lo resolviera en silencio de fondo. Esa columna search_vector es una columna generada, Postgres la construye automáticamente a partir del título y el contenido cada vez que una fila cambia, y es contra lo que realmente corre la búsqueda de texto completo del blog. No sabía que esa función existía hasta que la necesité.

corriendo una migración contra blog_db

0 comentarios

Inicia sesión para comentar.

Iniciar sesión

¿No tienes cuenta?