Actualizar un proyecto
jfast upgrade --check # qué se rompe, para ESTE proyecto
jfast upgrade --check --json # lo mismo, para un agente o un paso de CI
Compara la versión del framework que el proyecto fija contra la instalada, y reporta los cambios que hay en medio que este proyecto puede sentir de verdad.
Lo que no es
No es el changelog. CHANGELOG.md dice qué cambió; quien hace la actualización
necesita saber qué cambia para él, y veinte notas de release sin forma de
saber cuáles tres aplican es una lista que nadie lee dos veces.
Así que el comando no parsea el changelog — ni podría aunque quisiera. Los
cambios que rompen están declarados como datos dentro del paquete, en
jfastframework/upgrades.py, cada uno con un detect que inspecciona tu
proyecto en disco. Tres razones por las que ese archivo es la fuente y la prosa
no:
- La prosa no es un formato de datos.
### Breakinges un encabezado hoy. Renómbralo a### Breaking changesy el parser reporta que nada se rompió — y lo reporta con toda confianza. - El changelog no se publica. El wheel contiene
packages = ["src/jfastframework"]y nada más, así que un framework instalado no tiene changelog que leer. - Una nota de release no es un hallazgo. "Los timestamps son timezone-aware"
es una frase.
ALTER TABLE invoices ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC'es algo que puedes ejecutar.
La regla que lo hace valer la pena
Un cambio que este proyecto no puede sufrir no se imprime.
No es cortesía, es todo el diseño. Una advertencia que no aplica le enseña al lector que la salida es relleno, y la siguiente advertencia — la que importaba — se salta junto con ella.
Por eso cada entrada lleva un detect que devuelve la evidencia encontrada en
tu árbol:
| Cambio | Se reporta solo cuando |
|---|---|
timestamps-timezone-aware |
una clase de modelo lleva TimestampMixin |
contracts-shared-import |
una capa de contracts.toml omite "shared" |
contracts-layout-mismatch |
los paths de una capa no matchean el layout de ningún módulo |
refresh-tokens-rejected |
auth está habilitado y issue_tokens = true |
logout-ends-one-session |
igual |
token-store-rotate-refresh |
alguna clase tuya define rotate_refresh |
pagination-total-optional |
algún archivo llama a paginate o paginate_keyset |
access-token-fam-claim |
auth está habilitado y issue_tokens = true |
refresh-grace-seconds |
igual, y refresh_grace_seconds está sin fijar |
request-limit-defaults |
jfast.toml no fija el límite por su cuenta |
cli-exit-codes |
siempre — ver abajo |
Un servicio que solo valida tokens ajenos no se ve afectado por ningún cambio
en los endpoints que los emiten, que es el caso de la mayoría de los servicios
con auth encendido. Nunca se le informa de ellos.
cli-exit-codes es la excepción, y es honesta al respecto: nada en un proyecto
dice si su pipeline se bifurca según un exit code, así que la entrada se marca
como informativa y enuncia el cambio sin condiciones en vez de adivinar.
El mixin se resuelve por clase, no por archivo
Los nombres de tabla del remedio de timestamps-timezone-aware salen del
__tablename__ de cada modelo, leído con ast y sin importar el módulo. Valen
tanto __tablename__ = "invoices" como la forma anotada
__tablename__: str = "invoices"; una clase que calcula su nombre en tiempo de
ejecución se reporta como carries TimestampMixin, declares no
__tablename__ en vez de quedar afuera, y una clase marcada
__abstract__ = True no es dueña de ninguna tabla y se salta.
Qué clases llevan el mixin se decide por clase. Un archivo de modelos
suele tener tanto las tablas que mezclan los timestamps como tablas de
proyección o de vista que no, y un ALTER que nombra created_at sobre una
tabla que no lo tiene no es una advertencia:
ERROR: column "created_at" does not exist
Eso aborta la revisión ahí mismo — después de que cada ALTER anterior ya tomó
ACCESS EXCLUSIVE y reescribió su propia tabla. Las clases base se siguen por
nombre en todo el proyecto, así que un modelo que llega al mixin a través de
una base declarada en shared/ también se encuentra.
Qué reporta
0.1.0a3 → 0.1.0a4 (pinned in requirements.txt)
✗ timestamps-timezone-aware [breaking, 0.1.0a4]
TimestampMixin columns are timezone-aware. Existing tables need a migration.
created_at and updated_at mapped to TIMESTAMP WITHOUT TIME ZONE,
so a row serialised as 2026-08-29T20:55:15 with no offset and
every JavaScript client read it as local time.
in this project:
modules/invoice/models.py -> invoices
ALTER TABLE invoices
ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';
→ fix
Write the statements above into an Alembic revision by hand.
The USING clause is load-bearing and autogenerate omits it.
✗ contracts-shared-import [breaking, 0.1.0a4]
...
in this project:
[layers.http] may_import = ["service", "schemas", "storage"] -> add "shared"
7 apply here, 5 breaking.
La cláusula USING no es decoración
El autogenerate de Alembic escribe la forma pelada:
ALTER TABLE invoices ALTER COLUMN created_at TYPE timestamptz;
Eso no falla. Convierte a través del cast implícito, que lee cada valor
almacenado en el TimeZone del servidor, y desplaza en silencio la tabla
entera en cualquier servidor que no esté en UTC. La migración que imprime este
comando es la que no lo hace:
ALTER TABLE invoices
ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE timestamptz USING updated_at AT TIME ZONE 'UTC';
De dónde sale la versión del proyecto
Dos lugares la dicen, y se consultan en este orden:
requirements.txt— la líneajfastframework[...]==X. Esta gana, porque es sobre la que actúapip: un proyecto actualizado editando esa línea y reinstalando está en la versión nueva, sin importar qué recuerde el resto del disco..jfast-template— el sello que deja cada scaffold, con la versión del framework que generó el árbol. El respaldo para un proyecto sin archivo de requirements.
Que no exista ninguno de los dos es un 2 (error de configuración), no un éxito
silencioso: el comando se niega a reportar sobre una versión que tuvo que
adivinar.
La comparación respeta PEP 440, así que 0.1.0a10 es más nueva que 0.1.0a9 —
que no es lo que dice la comparación de cadenas. packaging no es una
dependencia de este framework, ni directa ni transitiva, así que el orden está
implementado a mano en upgrades.parse_version en vez de importarse; la suite
de tests lo fija contra el packaging real, que sí está instalado para
desarrollo.
Lo que cambia en 0.1.0a12
Correcciones, y una es de seguridad y cambia respuestas:
-
unsigned-tenant-needs-a-session--authytenancyactivos, consubdomain,pathoheaderentre las fuentes (el default del plugin, y lo que escribejfast new service --with auth,tenancy). Esas fuentes ya no otorgan un tenant por sí solas: un request enacme.sin sesión es 401 dondecurrent_tenantle entregaba las filas de acme, y un token cuyo tenant no coincide con el subdominio -- o que no trae ninguno -- es 403. Las rutas que usancurrent_tenantno necesitan nada; una página pública a propósito tomaDepends(requested_tenant); un servicio cuyos tokens no traen tenant y que revisa la membresía por su cuenta pone[plugin.tenancy] trust_unscoped_principals = true. Ver multi-tenancy. Un servicio de Go generado antes tiene el mismo hueco en suinternal/jfast/tenancy.gocopiado, yjfast upgrade --checkno lee Go: si poneJFAST_TENANCY_SOURCESconsubdomain,pathoheadery auth está activo, copiatenancy.gode un servicio generado por 0.1.0a12 y pásale el*jfast.Authajfast.NewTenancy(tenancyConfig, cfg, auth, logger). -
jfast-env-wins-over-the-file-- un ajuste que depende de dónde corre el servicio, escrito enjfast.toml:[app] env(que tiene todo proyecto que generójfast start),debug,[plugin.auth] issuer = ""(modo jwks), un backend de correo, un tope del LLM, un DSN -- la tabla completa está en deploy. El entorno del proceso ahora le gana a cada uno, así que un despliegue que poneJFAST_ENV=prodpor fin corre como producción (/docs,/infoy/queue/statsse cierran, y un plugin que quedó en un backend de desarrollo se niega a arrancar), y uno que poneJFAST_MAIL_BACKEND=smtppor fin manda correo. La nota lista un valor que alguien eligió y las dos líneas que 0.1.0a12 ya no escribe; deja fuera los defaults de desarrollo que el generador sigue escribiendo (json_logs = false,serve_local = true,base_domain = "localhost", ...), para los que el cambio es el arreglo mismo. Borra lo que solo es un default de desarrollo, pon el valor real en elenvironment:de compose y lee el log de arranque por las líneasoverridden by ... from the environment--jfast checklista lo mismo, con archivo y línea.
Nada más detiene a un servicio correcto de 0.1.0a11. Tres notas señalan
archivos que 0.1.0a11 generó y que hay que tocar:
image-cannot-write-local-storage-- un servicio constoragecuyo Dockerfile corre comoappusersin ser dueño de/app(la imagen se detiene al arrancar), o sin crear la raíz de un disco local que no seapublicniprivate(/ready503, subidas 500).jfast deploy dockerfilela regenera desde[plugin.storage.disks]; un volumen ya creado como root necesita unchown(Storage).facade-tenant-optional-- un servicio multitenant cuyas fachadaspublic.pyaceptantenant_id: str | None: None lee todos los tenants. Cámbialo atenant_id: stry deja que mypy nombre a quien lo llama.unique-key-on-optional-field-- una llave--uniquesobre un campo?, todavía como restricciónNULLS NOT DISTINCT: la segunda fila sin valor es un 409. Reemplázala por el índice parcial que cita la nota, en una migración.
Lo que cambia en 0.1.0a11
Nada en esta versión impide arrancar a un servicio correcto de 0.1.0a10. Lo
que se rompe es comportamiento que antes fallaba en silencio y ahora falla en
voz alta, más algunos ajustes que los plugins rechazan al arrancar. La
migración real de un servicio de cuatro módulos (un SaaS de comprobantes sobre
PostgreSQL, Redis, accounts y una cola) fue así, y es el orden a seguir.
1. Mueve el pin y luego lee el reporte. Edita requirements.txt a
0.1.0a11 después de correr jfast upgrade --check con el pin viejo: el
reporte lee la versión de esa línea. Con los extras nuevos instalados, corre
jfast check: su séptimo check, tenancy, es nuevo.
2. Cambia las tareas encoladas por nombre por un evento. contracts check
ahora ve Job(task="alerta.revisar_presupuesto") en otro módulo como una
llamada a él (undeclared-dependency, y module-cycle si el otro lee de
vuelta). El handler puede vivir en un worker.py en la raíz: el prefijo
<módulo>. del nombre dice de quién es la tarea.
# antes -- modules/comprobante/services/comprobante_service.py
await outbox.enqueue(session, Job(task="alerta.revisar_presupuesto", payload=...))
# después
await outbox.publish(session, "comprobantes", Event(type="comprobante.registrado", data=...))
# modules/alerta/tasks.py
@subscribe("comprobante.registrado")
async def revisar_presupuesto(event: Event, session: TaskSession) -> None:
... # corre como el tenant que publicó; commit al volver
# contracts.toml
[modules.comprobante]
publishes = ["comprobante.registrado"]
Sin suscriptor y sin bus de eventos, outbox.publish ahora lanza
UndeliverableEvent (un 500 que dice cómo arreglarlo) en vez de responder 201
y reintentar la fila hasta que muriera. publish-without-receiver encuentra
esas llamadas.
3. Borra worker.py; corre jfast worker. Los handlers van en
modules/<nombre>/tasks.py (@task, @subscribe), un TaskSession reemplaza
la sesión, el commit y la revisión de tenant escritos a mano, y jfast worker
drena al recibir SIGTERM. jfast dev lo arranca. Regenera los despliegues
(jfast deploy compose, jfast workspace compose, jfast workspace k8s):
ganan un servicio worker. Copia el bloque [layers.tasks] a contracts.toml
para que los tasks tengan capa (contracts-tasks-layer).
4. Revisa los defaults que cambiaron. Cuatro vienen encendidos y se
reportan como comportamiento: límites de inicio de sesión con cache
(accounts-sign-in-rate-limit), revocación de tokens que falla abierta cuando
Redis está caído (revocation-fail-open), un deadline de 1 s por comando de
Redis (redis-command-timeout) y errores de conexión a la base que responden
503 en vez de 500 (database-unavailable-503). Cada nota dice cómo conservar
el comportamiento anterior.
5. Ajustes rechazados al arrancar. settings-refused-at-boot lista los
valores que los plugins ya rechazan: pool_size = 0, un session_timezone que
no es IANA, una visibility de storage que no es public ni private, un
access_key sin su secreto, y más. Solo lee jfast.toml; los valores del
entorno se revisan al arrancar el servicio.
6. Clientes de accounts. Con email_verification = "required", los
usuarios existentes quedan sin verificar hasta correr el UPDATE de la nota.
Con mfa = true, /auth/login puede responder un reto en vez de tokens. Un
frontend generado que lee el usuario de la respuesta del login tiene que llamar
/auth/account después de iniciar sesión; jfast upgrade --check lo encuentra
junto al servicio.
jfast add y tu pin. jfast add <plugin> edita requirements.txt y corre
pip, excepto cuando el pin no es la versión que estás usando o la instalación
es editable. Entonces imprime el comando en su lugar: instalar el pin viejo
reemplazaría el framework con el que estás trabajando.
El que detiene un arranque en 0.1.0a9
Una sesión que confirma después de la respuesta
PluginError: these routes open a database session that would commit after the
response is sent ...
POST /invoices -> session_dependency
Antes: un commit fallido -- una constraint diferida, un fallo de serialización,
una conexión que se cae en el momento equivocado -- ya se había contestado
201, y un cliente que leía su propia escritura enseguida podía llegar antes
que el commit. FastAPI corre el desmontaje de una dependencia con yield
después de la respuesta salvo que tenga scope de función, y la sesión confirma
en su desmontaje. Todo módulo que jfast new module generó antes de 0.1.0a9
la conecta así.
La corrección es una línea por dependencia:
# antes
def get_service(request: Request, session=Depends(session_dependency)) -> Service: ...
# después
from jfastframework.plugins.builtin.database import DbSession
def get_service(request: Request, session: DbSession) -> Service: ...
ReadSession reemplaza a read_session_dependency y TenantSession a
tenant_session_dependency. Depends(session_dependency, scope="function") es
lo mismo escrito completo. Una dependencia generadora propia que envuelva una
sesión también debe tener scope de función -- FastAPI rechaza el otro orden.
jfast upgrade --check lista cada línea como session-commits-after-response.
El framework ahora necesita FastAPI 0.121 o posterior; pip install -U lo
resuelve. Transacciones explica el resto.
Exit codes
| Código | Significado |
|---|---|
0 |
nada entre esas versiones afecta a este proyecto |
2 |
no hay jfast.toml, o no hay pin contra el cual comparar |
6 |
se pasó --apply |
7 |
algo aplica, o el proyecto fija una versión más nueva que la instalada |
7 es COMPATIBILITY: el proyecto y el framework instalado no coinciden. Pon
un deploy detrás de eso.
--apply no existe
No es "todavía no en este build": no está planeado para este release, a propósito.
Reescribir automáticamente los modelos, el contrato y la configuración de
alguien necesita una historia de rollback: un árbol limpio del cual partir, un
diff que revisar, una forma de volver cuando la reescritura está mal. Nada de
eso existe aquí, y una edición automática equivocada cuesta más que la manual
que ahorró. Pasar --apply lo dice y sale con 6.
El reporte nombra el archivo y la línea de cada hallazgo. Haz las ediciones.
Agregar un cambio al manifiesto
Cuando un release rompa algo, agrega un Change a CHANGES en
jfastframework/upgrades.py:
Change(
version="0.1.0a5",
kind="breaking", # breaking | deprecated | behaviour
code="stable-identifier", # what --json emits; never reuse one
summary="One line. What broke.",
detail="Why it broke and what the silent failure looked like.",
detect=_something_on_disk, # None only when nothing can decide it
remedy="What to do, concretely.",
)
detect recibe un jfastframework.project.Project y devuelve las cadenas de
evidencia — nombres de tablas, nombres de capas, los valores que un setting está
por adquirir. Una lista vacía significa que el proyecto no está afectado y la
entrada no se imprime.
Project se lee del sistema de archivos y nunca importa el código del proyecto,
así que el reporte funciona sobre un servicio roto, a medio migrar o sin sus
dependencias instaladas. Lleva los nombres de los plugins pero no su
configuración; lee jfast.toml directamente cuando un cambio dependa de ella,
como hacen las entradas de auth.
Escribe la entrada con detect=None solo cuando nada en disco pueda decidir la
pregunta, y dilo en detail. Es la diferencia entre una nota informativa honesta
y una advertencia que la gente aprende a ignorar.
Después dale dos tests en tests/test_upgrade_detectors.py -- un proyecto que
marca y el más parecido que no debe marcar -- y regístralos en AFFECTED y
CLEAN. El último test de ese archivo los compara con CHANGES, así que una
entrada sin ellos rompe la suite.
El smoke de actualización
scripts/smoke_upgrade.sh recorre el camino que recorre un usuario, en cada
cambio:
- un virtualenv con la versión anterior desde PyPI;
- un proyecto generado con ella -- un módulo en cada layout, y los plugins
auth,tenancy,metrics,ragyqueue; - el wheel de este checkout instalado encima;
jfast upgrade --check --json, cuyos códigos de cambio deben ser exactamente los descripts/smoke_upgrade.expected-- uno que falta significa que un detector dejó de ver su caso, uno de más que empezó a ver uno que no está;- el remedio de cada código, escrito en el smoke como lo describe el
remedyde la entrada; - la app importada y
GET /healthrespondido en el mismo proceso; - el
pytestdel propio proyecto yjfast check --ci.
Lo que mantiene honesto es lo que promete esta página: que upgrade --check
nombra todo lo que un proyecto tiene que cambiar. Una rotura que no nombra
aparece en el paso 6 o 7 sin nada en el paso 4 -- así encontró que la plantilla
hexagonal de 0.1.0a10 no pasa su propio test (el init del paquete importa
FastAPI a través de CreatePayload).
PY=python3.12 scripts/smoke_upgrade.sh # anterior = esta versión menos una
PREVIOUS=0.1.0a9 scripts/smoke_upgrade.sh # o nómbrala
KEEP_WORK=1 scripts/smoke_upgrade.sh # deja el proyecto para revisarlo
Un Change nuevo que aplica al proyecto generado necesita su código en el
archivo esperado y un case en remedy() del smoke; el smoke falla y lo dice
hasta que estén los dos.