Volver a la wiki

Incidente: Duplicación de Quick Links por gap en d1_migrations tracking (14-05-2026)

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).

CampoValor
Fecha detección2026-05-14
SeveridadMedia (datos duplicados en BD, sin downtime)
EntornoCloudflare D1 – producción (crearacksl-workspace-db)
Registros afectados~31 Quick Links duplicados (~25 de migración 0018 + 6 de migración 0019)
Fix desplegadomigración 0025_quick_links_unique.sql + endpoint hardening
Commitdce4f725a6b3a5ebd3be00c68581ecb59df60eba

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:

  1. Wrangler consultó d1_migrations y encontró que 0015–0024 estaban “pendientes”.
  2. Aplicó 0018_seed_quick_links_initial.sql (25 entries) y 0019_seed_quick_links_extended.sql (6 entries) de nuevo, silenciosamente.
  3. La tabla quick_links aceptó los INSERTs porque id es AUTOINCREMENT → nuevas PKs → sin colisión → INSERT OR IGNORE no actuó.

Por qué no hubo protección a nivel de datos


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:xxSe detectan duplicados en la UI de Quick Links
2026-05-14 18:01PR #40 mergeado a main con fix en 3 capas
Post-mergewrangler d1 execute ... --file=migrations/0025_quick_links_unique.sql ejecutado en remoto

Fix aplicado — 3 capas

-- 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 *

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


Lecciones aprendidas

  1. INSERT OR IGNORE no es idempotencia semántica — solo previene colisiones de PK. Los seeds necesitan UNIQUE constraints explícitos en las columnas de identidad de negocio.
  2. wrangler d1 migrations apply + gap de tracking = peligro silencioso — si la tabla d1_migrations no refleja el estado real, el comando puede re-aplicar migraciones destructivas/aditivas sin advertencia.
  3. Patrón recomendado para migraciones manuales: registrar siempre en d1_migrations con INSERT INTO d1_migrations (name, applied_at) VALUES ('0025_...', datetime('now')) tras cada wrangler d1 execute directo.

Véase también

Subir