Backend y arquitectura
Sincronizar contenido en cada despliegue sin pisar lo editado en el panel
6 min de lectura
Sincronizar contenido en cada despliegue sin pisar lo editado en el panel
El problema
Rehacer mi portfolio implicaba mucho contenido nuevo de golpe: categorías de proyectos, unos veintiséis proyectos, experiencia, formación, una limpieza de skills y los primeros artículos del blog, todo en tres idiomas. Meterlo a mano en el panel de administración de producción habría sido lento, propenso a errores e imposible de revisar. Al mismo tiempo, una vez publicada la web, también edito cosas directamente en el panel. Necesitaba las dos cosas: el contenido como código, revisado en git y aplicado automáticamente, y el panel como editor de primera clase cuyos cambios nunca se deshacen en silencio.
El contenido como fichero PHP versionado
Todo el contenido preparado vive en database/content/portfolio.php, un fichero que devuelve un array normal: categorías, skills (con listas para crear o actualizar, fusionar y borrar), proyectos, experiencia, formación, secciones y artículos del blog, y mi texto de presentación. Los campos traducibles tienen exactamente la misma forma que en la base de datos:
'laringe' => [
'match' => ['id' => 19],
'category' => 'health-cv',
'title' => [
'en' => 'Laryngeal region segmentation and tracking',
'es' => 'Segmentación y seguimiento de la región laríngea',
'zh' => '喉部区域分割与跟踪',
],
'destacado' => true,
'skills' => ['Python', 'OpenCV'],
],
Un comando Artisan, portfolio:sync-content, lee ese fichero y lo aplica. El script de despliegue lo ejecuta justo después de las migraciones y antes de la comprobación de salud.
Una transacción y un modo de prueba
El comando lo ejecuta todo dentro de una única transacción de base de datos:
DB::beginTransaction();
try {
$categoryIds = $this->syncCategories($content['categories'] ?? []);
$this->syncSkills($content['skills'] ?? []);
$this->syncProjects($content['projects'] ?? [], $owner, $categoryIds);
// experiencia, formación, blog, perfil...
$dryRun ? DB::rollBack() : DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Con --dry-run imprime el informe completo de cambios (creados, emparejados, conservados) y después revierte, así que puedo ver exactamente qué haría un despliegue. Una ejecución real es todo o nada: si algo falla a mitad, no queda nada aplicado a medias, y ejecutarlo dos veces deja el mismo estado final que ejecutarlo una.
La primera lección llegó con el primer despliegue. El código nuevo esperaba que existieran categorías de proyectos y lanzaba una excepción si no había ninguna, pero la sincronización todavía no formaba parte del despliegue. La comprobación de salud falló y el despliegue volvió a la versión anterior. Las migraciones, en cambio, se quedan aplicadas. Añadir la sincronización al proceso de despliegue, antes de la comprobación de salud, lo resolvió. Si el código depende de ciertos datos, cargarlos forma parte del despliegue, no de un paso manual posterior.
Emparejar filas: claves estables e ids heredados
Cada entidad del fichero tiene una key estable, como laringe. Producción, sin embargo, ya tenía filas de la web antigua sin ninguna clave. Por eso el emparejamiento sigue tres pasos: primero por clave; si no la hay, por el match.id declarado en el fichero, adoptando esa fila existente y poniéndole su clave; y si no, creando una fila nueva.
private function matchByKey(string $modelClass, string $key, ?int $matchId): Model
{
$byKey = $modelClass::query()->where('key', $key)->first();
if ($byKey) {
return $byKey;
}
if ($matchId) {
$byId = $modelClass::query()->find($matchId);
if ($byId && $byId->key === null) {
$byId->key = $key;
return $byId;
}
}
return new $modelClass(['key' => $key]);
}
La comprobación $byId->key === null es la segunda lección. Sin ella, el comando funcionaba en producción, donde los ids heredados ya existen, pero fallaba en silencio sobre una base de datos nueva. Allí el autoincremento reparte ids durante la misma ejecución. Un proyecto creado al principio recibía el id 1; una entidad posterior cuyo match.id era 1 encontraba esa fila recién creada, la «adoptaba» y la sobrescribía con sus propios datos. Un proyecto se convertía en otro sin que nadie lo notara. Adoptar por id solo cuando la fila todavía no tiene clave cierra esa puerta.
Los tests también cambiaron. Antes se ejecutaban sobre una base de datos vacía, que resultó ser el caso engañoso. Ahora primero insertan filas heredadas con los ids reales, que es el estado que el comando se encuentra de verdad en producción.
Proteger lo editado en el panel con hashes por campo
Por cada campo traducible que escribe, el comando guarda un SHA1 del valor JSON en una tabla content_sync_hashes, indexada por modelo, fila y campo. En la siguiente ejecución:
- si el valor actual ya no coincide con el hash guardado, alguien lo editó en el panel desde la última sincronización, así que el comando lo conserva e informa «kept (edited in panel)»;
- si el hash guardado ya coincide con el valor nuevo, no hay nada que hacer;
- en otro caso, escribe el valor nuevo y actualiza el hash.
if ($hashRow && $hashRow->hash !== $currentHash) {
$this->changeLog[] = "kept (edited in panel): {$this->label($model)}.{$field}";
return;
}
Trabajar por campo importa: corregir un título en español desde el panel no debería congelar toda la fila frente a futuras actualizaciones de contenido.
Hay una limitación. El hash es tan fiable como el historial que lo respalda. En mi base de datos local, valores escritos fuera del comando hicieron que dos artículos parecieran «editados en el panel» sin que nadie los hubiera tocado desde el admin. Aun así, es la dirección correcta en la que fallar: el comando conserva los datos en lugar de sobrescribirlos, y el informe lo dice de forma explícita.
Los campos que el hash no cubría
La protección por hash solo se aplicaba a los campos traducibles. published_at se asignaba sin más desde el fichero en cada ejecución. Los borradores tenían null en el fichero, publiqué uno desde el panel, y el siguiente despliegue lo despublicó. Esa fue la tercera lección. Ahora la regla es explícita:
if ($isNew && array_key_exists('published_at', $data)) {
$model->published_at = $data['published_at'];
}
El fichero solo fija la fecha cuando se crea el artículo. A partir de ahí, publicarlo, programarlo o devolverlo a borrador se decide en el panel, y esa decisión sobrevive a todos los despliegues siguientes.
También existe el caso contrario. Los campos que el fichero no declara nunca se tocan, lo cual es correcto para los datos que gestiona el panel. Pero también significa que el contenido heredado que el fichero no menciona, como los antiguos textos de proyectos solo en inglés, se queda tal cual hasta que el fichero asume explícitamente ese campo.
Lo que mantendría
- El contenido en git, revisado como código y aplicado por un comando idempotente que el despliegue ejecuta solo.
- Un modo de prueba antes que nada, y una única transacción que lo envuelva todo.
- Decidir desde el principio quién es dueño de cada campo: el fichero, el panel, o «el fichero solo lo fija al crear». Haber escrito esa tabla pronto me habría ahorrado dos de estos tres bugs.