Skip to content

0041. Seed de demo: idempotencia por piscina y guard de entorno por nombre de base

Estado

Aceptada

Contexto

prisma/seed.ts repuebla la BD (tras prisma migrate reset) con 2 organizaciones multi-tenant y datos mínimos en todos los módulos de negocio, para poder ver cada pantalla del admin/mobile con datos reales sin crearlos a mano. Al ser un script standalone (no bootea AppModule, para evitar el costo de la DI de Nest), no puede reusar directamente PermissionsService.seedDefaultRolesForOrg ni las validaciones de ciclos.service.ts; tuvo que reimplementar esa lógica con el cliente Prisma pelado.

Dos decisiones no obvias surgieron al implementarlo y verificarlo end-to-end:

1. Nivel de idempotencia. Reejecutar el seed no debe duplicar datos. Hacer upsert campo a campo de cada muestreo/alimentación/parámetro sería más granular pero mucho más código. Se optó por chequear poolHasCycle(poolId): si una piscina demo ya tiene un ciclo, se omite toda su historia de negocio (siembra, llenado, muestreos, alimentación, etc.) de una vez.

2. Riesgo real encontrado en /code-review al cerrar la tarea: combinar esa idempotencia gruesa con una escritura no transaccional en la transferencia madre→engorde podía dejar datos huérfanos. Si Cycle.create (destino) se completaba pero Transfer.create fallaba a mitad de camino, quedaba un ciclo de engorde sin su Transfer asociado —un estado que la API real nunca produce, porque ciclos.service.ts#createTransfer sí envuelve ambas escrituras en $transaction—. Y como el guard de idempotencia solo mira el pool origen (poolHasCycle sobre la piscina precriadero), un rerun después de esa falla habría saltado silenciosamente toda la historia sin reparar el pool destino.

3. Guard de entorno demasiado laxo. La primera versión validaba con /dev|demo/i.test(DATABASE_URL) sobre la connection string completa. Un usuario de servicio como devops_svc en una URL de producción hace matchear ese regex y deja pasar el seed — insertando en prod 6 usuarios demo con password fijo (12345678), justo lo que el guard existe para evitar.

Decisión

  1. Idempotencia a nivel de piscina, documentada como trade-off aceptado: si el script cambia más adelante (fechas, montos), reejecutarlo sobre una BD ya sembrada NO actualiza los ciclos existentes — solo evita duplicarlos. Para refrescar con cambios del script hace falta prisma migrate reset de nuevo. Aceptable para un seed de demo (no para datos reales).
  2. seedTransferChain usa prisma.$transaction para el Cycle destino + el Transfer, igual que el servicio real que replica. Así una falla a mitad de camino no dispara el problema de (2): o se crea todo el par consistente, o no se crea nada (y el próximo rerun sí lo reintenta, porque poolHasCycle seguiría en false).
  3. El guard de entorno evalúa solo el NOMBRE de la base (pathname de la URL, no la connection string completa) contra /(^|_)(dev|demo|test)($|_)/i — un usuario o host que contenga esas palabras como substring ya no hace pasar el guard. Si DATABASE_URL no es parseable, el guard rechaza por defecto (falla cerrado). El mensaje de error muestra la URL con usuario/password redactados (redactDatabaseUrl), para no filtrar credenciales en logs si el guard aborta.

Consecuencias

  • El seed sigue siendo re-ejecutable sin duplicar, pero no es un mecanismo de sincronización continua: cambios al script requieren reset para reflejarse en una BD ya sembrada.
  • seedTransferChain es el único punto del script con $transaction — los demás helpers (ensureOrgUser, ensurePool, etc.) son upserts idempotentes de una sola escritura y no lo necesitan.
  • El guard sigue siendo una heurística (no una allowlist explícita de bases autorizadas); SEED_DEMO_FORCE=true sigue siendo el override documentado para casos legítimos fuera del patrón dev/demo/test.

Referencias

  • Tarea seed-demo (2026-07-15): _planning/_done/seed-demo-* (tras archivar).
  • camaroneras_backend/prisma/seed.ts.
  • ADR-0028 — invariantes de ciclos/transferencias que el seed replica.