Incidente: Duplicación de Quick Links por gap en d1_migrations tracking
Resumen ejecutivo
El 14-05-2026, al aplicar wrangler d1 migrations apply --remote para desplegar la migración 0024_kind_lifecycle_drift, wrangler re-aplicó silenciosamente las migraciones 0018 y 0019 porque nunca habían sido registradas en la tabla d1_migrations. Esto provocó la duplicación de los ~31 Quick Links seedeados en producción. La tabla quick_links carecía de UNIQUE constraint en (label, url), por lo que el INSERT OR IGNORE de las migraciones no ofreció protección (IGNORE solo actúa sobre colisiones de PK/rowid).
| Campo | Valor |
|---|---|
| Fecha detección | 2026-05-14 |
| Severidad | Media (datos duplicados en BD, sin downtime) |
| Entorno | Cloudflare D1 – producción (crearacksl-workspace-db) |
| Registros afectados | ~31 Quick Links duplicados (~25 de migración 0018 + 6 de migración 0019) |
| Fix desplegado | migración 0025_quick_links_unique.sql + endpoint hardening |
| Commit | dce4f725a6b3a5ebd3be00c68581ecb59df60eba |
Causa raíz
El gap de tracking en d1_migrations
El script scripts/seed-d1-migrations.sql solo registró las migraciones 0001–0014 en la tabla d1_migrations de wrangler. Las migraciones 0015–0024 se aplicaron manualmente (patrón wrangler d1 execute ... --file=migrations/...) pero nunca se marcaron como aplicadas en esa tabla de control.
Cuando en sesión s61-s62 se ejecutó wrangler d1 migrations apply --remote para la 0024:
- Wrangler consultó
d1_migrationsy encontró que0015–0024estaban “pendientes”. - Aplicó
0018_seed_quick_links_initial.sql(25 entries) y0019_seed_quick_links_extended.sql(6 entries) de nuevo, silenciosamente. - La tabla
quick_linksaceptó los INSERTs porqueidesAUTOINCREMENT→ nuevas PKs → sin colisión →INSERT OR IGNOREno actuó.
Por qué no hubo protección a nivel de datos
INSERT OR IGNOREsolo previene conflictos de PK o constraints explícitos.quick_linksno teníaUNIQUE INDEXen(label, url), el par que identifica unicidad semántica.- Resultado: duplicados perfectamente válidos a ojos de SQLite/D1.
Cronología
| Hora (UTC aprox.) | Evento |
|---|---|
| Sesiones s61-s62 (previas) | Se aplica 0024_kind_lifecycle_drift vía migrations apply, wrangler re-aplica 0018+0019 silenciosamente |
| 2026-05-14 ~17:xx | Se detectan duplicados en la UI de Quick Links |
| 2026-05-14 18:01 | PR #40 mergeado a main con fix en 3 capas |
| Post-merge | wrangler d1 execute ... --file=migrations/0025_quick_links_unique.sql ejecutado en remoto |
Fix aplicado — 3 capas
Capa 1: Migración 0025_quick_links_unique.sql
-- 1. Deduplicar: conservar la fila más antigua (menor id) de cada (label, url)
DELETE FROM quick_links
WHERE id NOT IN (
SELECT MIN(id) FROM quick_links GROUP BY label, url
);
-- 2. Constraint a nivel BD para prevenir recurrencia
CREATE UNIQUE INDEX IF NOT EXISTS idx_quick_links_label_url_unique
ON quick_links(label, url);
Idempotente: DELETE por GROUP BY no toca filas únicas; CREATE UNIQUE INDEX IF NOT EXISTS es re-ejecutable.
Capa 2: functions/api/quicklinks/index.ts — POST hardening
INSERT INTO quick_links (label, url, description, category, icon_url, position, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(label, url) DO NOTHING
RETURNING *
- Si el INSERT es ignorado por conflict,
RETURNING *devuelve null → el handler responde HTTP 409 con{ error: 'Ya existe un Quick Link con ese nombre y URL' }. - Protege contra dobles POST del modal de creación y contra cualquier re-seed futuro.
Capa 3: functions/api/quicklinks/[id].ts — PUT hardening
El UPDATE que modifica label o url puede ahora violar el UNIQUE INDEX si otro registro ya tiene ese par. Se envuelve en try/catch:
try {
result = await env.DB.prepare(
`UPDATE quick_links SET ${sets.join(', ')} WHERE id = ? RETURNING *`
).bind(...binds).first();
} catch (err) {
if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
return new Response(
JSON.stringify({ error: 'Otro Quick Link ya tiene ese nombre y URL' }),
{ status: 409 }
);
}
throw err;
}
Acciones pendientes
- Cleanup de
d1_migrationstracking — registrar end1_migrationslas entradas para0015–0024para que futuroswrangler d1 migrations applyno vuelvan a creer que están pendientes. (Pendiente, sesión futura.) - Verificar en UI que los Quick Links en producción quedaron sin duplicados tras la migración.
- Revisar si otras tablas seedeadas en
0015–0024tienen el mismo riesgo de re-aplicación sin UNIQUE constraints.
Lecciones aprendidas
INSERT OR IGNOREno es idempotencia semántica — solo previene colisiones de PK. Los seeds necesitan UNIQUE constraints explícitos en las columnas de identidad de negocio.wrangler d1 migrations apply+ gap de tracking = peligro silencioso — si la tablad1_migrationsno refleja el estado real, el comando puede re-aplicar migraciones destructivas/aditivas sin advertencia.- Patrón recomendado para migraciones manuales: registrar siempre en
d1_migrationsconINSERT INTO d1_migrations (name, applied_at) VALUES ('0025_...', datetime('now'))tras cadawrangler d1 executedirecto.
Véase también
- [[entity—workspace—endpoint—quicklinks]]
- [[workspace—que-es-workspace]]
- [[workspace—agentes-ia]]