C2 — Schema + workflow fitz db end-to-end¶
Pre-requisitos: C1 — Setup Docker-first
cerrado. Tenés los 5 services arriba y curl http://localhost:8000/healthz
responde 200 OK. Sabés el workflow fitz db del cap
M6.C6 del curso
— acá lo aplicamos contra TaskHub en un setup Docker-first real.
Objetivo: declarar el schema del dominio (los 4 @table type:
User / Project / Task / Comment), aplicar el workflow completo
fitz db (new → editar @table → diff → editar UP/DOWN →
migrate → status), verificar contra Postgres, demostrar un
cambio de schema posterior + rollback, y dejar el drift
check fitz db check en GitHub Actions corriendo en CI.
Por qué importa: es la primera vez en TaskHub que tocamos
la DB real. Marca el patrón para todos los caps siguientes —
cualquier cap que agregue una columna o cambie un type pasa por
fitz db diff + migrate + rebuild del binario. El workflow
versionado (vs CREATE TABLE IF NOT EXISTS al boot del M6.C7
capstone) es lo que hace que TaskHub sea production-ready: cada
cambio queda registrado, es auditable, y CI lo valida.
Mapa del cap¶
flowchart LR
A[Estado C1: 5 services] --> B[Declarar 4 @table types]
B --> C[Agregar db.connect + GET /api/users smoke]
C --> D[fitz db new initial_schema]
D --> E[fitz db diff > file.sql]
E --> F[Editar secciones UP/DOWN]
F --> G[fitz db migrate]
G --> H[Verificar con psql]
H --> I[docker compose up --build]
I --> J[curl GET /api/users devuelve lista vacia]
J --> K[Cambio schema: agregar field]
K --> L[Segunda migration]
L --> M[Rollback de la ultima]
M --> N[CI con fitz db check + GitHub Actions]
Por qué Fitz es distinto en TaskHub¶
| Workflow | Stack típico Alembic | Stack típico TypeORM | TaskHub con fitz db |
|---|---|---|---|
| Source of truth del schema | modelos SQLAlchemy + env.py configurado |
clases @Entity + DataSource config |
@table type del lenguaje en src/main.fitz, ya tipado y validado por el checker |
| Setup inicial del workflow | pip install alembic + alembic init + editar env.py para importar models |
npm install typeorm + config DataSource + build TS |
Cero setup — fitz ya está en el host del dev |
| Conexión a la DB del compose | usa SQLAlchemy engine config | usa TypeORM DataSource | usa DATABASE_URL directo (el dev exporta del .env del compose) |
| Generar el SQL del cambio | alembic revision --autogenerate -m "add_field" ⚠ requiere import de models al boot del CLI |
typeorm migration:generate ⚠ requiere build TS |
fitz db diff > file.sql directo desde el binario, lee el @table del fuente |
Aplicar contra el container db |
alembic upgrade head |
typeorm migration:run |
fitz db migrate |
| Rebuild del binario tras schema change | no aplica (runtime interpretation) | no aplica (runtime interpretation) | docker compose up -d --build app — el binario nuevo incorpora el schema declarado en @table, no hay runtime reflection |
| Drift check para CI | con scripts custom + alembic check (parcial) |
con scripts custom | fitz db check exit 0/1 directo |
| Tipo de schema | objetos Python en runtime | clases TS compiladas | types Fitz validados estáticos en compile-time |
Diferencial estructural: el binario del container no tiene
runtime reflection. El schema que entiende el ORM es el que estaba
declarado en @table en el momento del fitz build. Por eso
después de cada fitz db migrate hace falta rebuild del binario
(docker compose up -d --build app). Es overhead manejable —
~30s — pero el binario resultante es 100% predecible. Cero
sorpresas runtime.
Paso 1 — Declarar el schema en código¶
Editás src/main.fitz para declarar los 4 @table type del
dominio. En C2 los declaramos sin @belongs_to (esos llegan en
C4 cuando los usamos para navigation methods). Las FK constraints
van en SQL manual en las migrations.
// src/main.fitz — TaskHub C2
// ───────────────────────────────────────────────
// Schema del dominio (4 tablas)
// ───────────────────────────────────────────────
@table("users") type User {
@primary id: Int = 0
email: Str
password_hash: Str = ""
role: Str = "member" // admin / owner / member (validado en C3)
created_at: DateTime
}
@table("projects") type Project {
@primary id: Int = 0
name: Str
description: Str = ""
owner_id: Int // FK → users.id (constraint en migration)
created_at: DateTime
}
@table("tasks") type Task {
@primary id: Int = 0
project_id: Int // FK → projects.id
title: Str
description: Str = ""
status: Str = "todo" // todo / doing / done
priority: Int = 3 // 1-5
assignee_id: Int? // FK nullable → users.id
due_date: Date?
ai_suggested_priority: Int? // cache del C6
created_at: DateTime
}
@table("comments") type Comment {
@primary id: Int = 0
task_id: Int // FK → tasks.id
user_id: Int // FK → users.id (autor del comment)
body: Str
created_at: DateTime
}
// ───────────────────────────────────────────────
// Conexión a la DB + smoke endpoint
// ───────────────────────────────────────────────
let db_result = db.connect(env_or("DATABASE_URL", "")).await
type HealthResponse {
status: Str
version: Str
}
@get("/healthz")
fn healthz() -> HealthResponse {
return HealthResponse {
status: "ok",
version: "0.1.0-c2"
}
}
// Smoke endpoint para validar que el binario habla con la DB.
// En C4 lo vamos a transformar en un GET autenticado scoped al user.
@get("/users")
async fn list_users() -> Result<List<User>> {
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
return User.all(conn).await
}
// Mismo @server que C1 (con host="0.0.0.0" para Docker).
@server(port=8080, host="0.0.0.0")
fn main() => 0
print("TaskHub C2 — schema declarado, escuchando en :8080")
Detalles importantes:
@primary id: Int = 0— sentinel0indica auto-asignación (bigserialPG). El ORM lo skipea en INSERT.created_at: DateTimesin default — vas a tener que pasarDateTime.now()cuando hagas INSERT en C4. (Alternativa:@db_default("NOW()")para que PG lo llene; lo dejamos como refinamiento futuro.)assignee_id: Int?— nullable. Postgres columnbigint NULL.role: Str = "member"— default a"member". Cuando registres usuarios sin especificar role, default a member.@get("/users")(no/api/users) porque nginx ya hace el strip del/api/prefix (vernginx.confdel C1). El cliente pega ahttp://localhost:8000/api/users, nginx lo reenvía ahttp://app:8080/users.
Paso 2 — Setup del DATABASE_URL en tu shell¶
fitz db se corre desde el host (no desde el container — el
runtime distroless no tiene shell). Necesita DATABASE_URL
apuntando a localhost:5432 (el compose expone el puerto del db).
Creá un helper script dev-env.sh en la raíz del proyecto:
#!/usr/bin/env bash
# dev-env.sh — exporta vars necesarias para correr fitz db
# desde el host contra el container db del compose.
#
# Uso: `source dev-env.sh`
if [ ! -f .env ]; then
echo "ERROR: .env no existe. Copialo desde .env.example primero."
return 1 2>/dev/null || exit 1
fi
# Carga el .env del compose.
set -a
source .env
set +a
# Construye DATABASE_URL apuntando al db expuesto en localhost.
export DATABASE_URL="postgres://taskhub:${DB_PASSWORD}@localhost:5432/taskhub?sslmode=disable"
echo "✓ DATABASE_URL exportada para fitz db (localhost:5432)"
echo " Ahora podés correr: fitz db status / diff / migrate / ..."
Verificación rápida:
psql "$DATABASE_URL" -c "SELECT current_database();"
# → current_database
# -----------------
# taskhub
Si esto falla, el container db no está arriba o tu .env está
desconfigurado — revisá C1 § Troubleshooting.
Paso 3 — Primera migration: fitz db new → diff → editar → migrate¶
3.1 — fitz db new initial_schema¶
El stub generado tiene secciones -- UP y -- DOWN vacías:
3.2 — fitz db diff para ver el SQL¶
Output al stdout (todo el SQL para crear las 4 tablas desde una DB vacía):
CREATE TABLE "users" (
"id" bigserial PRIMARY KEY,
"email" text NOT NULL,
"password_hash" text NOT NULL DEFAULT '',
"role" text NOT NULL DEFAULT 'member',
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "projects" (
"id" bigserial PRIMARY KEY,
"name" text NOT NULL,
"description" text NOT NULL DEFAULT '',
"owner_id" bigint NOT NULL,
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "tasks" (
"id" bigserial PRIMARY KEY,
"project_id" bigint NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL DEFAULT '',
"status" text NOT NULL DEFAULT 'todo',
"priority" bigint NOT NULL DEFAULT 3,
"assignee_id" bigint,
"due_date" date,
"ai_suggested_priority" bigint,
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "comments" (
"id" bigserial PRIMARY KEY,
"task_id" bigint NOT NULL,
"user_id" bigint NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone NOT NULL
);
Atajo útil: redirigís a un archivo temporal en vez de copiar/pegar:
3.3 — Editar el .sql con UP/DOWN + FK constraints¶
Editás migrations/20260607130000_initial_schema.sql:
- Pegás el SQL de
fitz db diffadentro de la sección-- UP. - Agregás las FK constraints (que el diff no emite porque los
types no tienen
@belongs_totodavía). - Escribís el
-- DOWNcon DROP en orden inverso de creación.
-- Migration: initial_schema
-- Created: 2026-06-07T13:00:00Z
-- UP
CREATE TABLE "users" (
"id" bigserial PRIMARY KEY,
"email" text NOT NULL UNIQUE,
"password_hash" text NOT NULL DEFAULT '',
"role" text NOT NULL DEFAULT 'member',
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "projects" (
"id" bigserial PRIMARY KEY,
"name" text NOT NULL,
"description" text NOT NULL DEFAULT '',
"owner_id" bigint NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "tasks" (
"id" bigserial PRIMARY KEY,
"project_id" bigint NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE,
"title" text NOT NULL,
"description" text NOT NULL DEFAULT '',
"status" text NOT NULL DEFAULT 'todo',
"priority" bigint NOT NULL DEFAULT 3,
"assignee_id" bigint REFERENCES "users"("id") ON DELETE SET NULL,
"due_date" date,
"ai_suggested_priority" bigint,
"created_at" timestamp with time zone NOT NULL
);
CREATE TABLE "comments" (
"id" bigserial PRIMARY KEY,
"task_id" bigint NOT NULL REFERENCES "tasks"("id") ON DELETE CASCADE,
"user_id" bigint NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"body" text NOT NULL,
"created_at" timestamp with time zone NOT NULL
);
-- Indexes recomendados para queries comunes.
CREATE INDEX "idx_projects_owner_id" ON "projects" ("owner_id");
CREATE INDEX "idx_tasks_project_id" ON "tasks" ("project_id");
CREATE INDEX "idx_tasks_assignee_id" ON "tasks" ("assignee_id");
CREATE INDEX "idx_comments_task_id" ON "comments" ("task_id");
CREATE INDEX "idx_comments_user_id" ON "comments" ("user_id");
-- DOWN
DROP TABLE "comments";
DROP TABLE "tasks";
DROP TABLE "projects";
DROP TABLE "users";
Detalles de las FK constraints:
ON DELETE CASCADEenprojects.owner_id,tasks.project_id,comments.task_id,comments.user_id: si borrás el padre, los hijos se borran automáticamente. Apropiado para data fuertemente acoplada.ON DELETE SET NULLentasks.assignee_id: si borrás al usuario asignado, la task queda sin assignee (no se borra la task).UNIQUEenusers.email: evitamos registros duplicados a nivel DB.
3.4 — fitz db migrate¶
fitz db status:
Migration Estado
-------------------------------------------- --------
20260607130000_initial_schema.sql ✓ applied
Paso 4 — Verificación con psql¶
psql "$DATABASE_URL" -c "\dt"
# List of relations
# Schema | Name | Type | Owner
# --------+-------------------+-------+----------
# public | _fitz_migrations | table | taskhub
# public | comments | table | taskhub
# public | projects | table | taskhub
# public | tasks | table | taskhub
# public | users | table | taskhub
psql "$DATABASE_URL" -c "SELECT * FROM _fitz_migrations;"
# version | applied_at
# -------------------+-------------------------------
# 20260607130000 | 2026-06-07 13:05:23.456789-00
# Detalle de columnas de `tasks`.
psql "$DATABASE_URL" -c "\d tasks"
Las 4 tablas + _fitz_migrations están creadas, los índices
existen, las FK están enlazadas. Postgres está listo. Falta
que el binario nuevo conozca el schema.
Paso 5 — Rebuild del binario con el schema nuevo¶
El binario corriendo en taskhub-app es el de C1 — no conoce los
4 @table type que recién declaraste. Hay que rebuild:
docker compose up -d --build app
# → [+] Building 25s (12/12) FINISHED
# → [+] Running 1/1
# ✔ Container taskhub-app Started
--build app rebuilds solo el service app (no rebuild
postgres / prometheus / jaeger / nginx — esos no cambiaron).
Verificación: el nuevo binario respondiendo:
curl http://localhost:8000/healthz
# → {"status":"ok","version":"0.1.0-c2"} ← version actualizada
curl http://localhost:8000/api/users
# → [] ← lista vacía, esperado
El endpoint smoke GET /api/users devuelve [] — significa
que el binario:
- Levantó conn al
db:5432(vía DATABASE_URL del compose). - Hizo
User.all(conn)sin error. - Postgres respondió
0 rows(la tabla existe y está vacía). - El ORM serializó la lista vacía a JSON.
- Nginx proxeó la response al cliente.
End-to-end OK — el stack completo (cliente → nginx → app → postgres) está vivo.
Paso 6 — Cambio de schema posterior¶
Caso típico de TaskHub: querés sumar un campo estimated_hours: Int = 0
a Task para que los usuarios estimen cuánto les va a tomar.
Editás el @table en src/main.fitz:
@table("tasks") type Task {
@primary id: Int = 0
project_id: Int
title: Str
description: Str = ""
status: Str = "todo"
priority: Int = 3
estimated_hours: Int = 0 // ← campo nuevo
assignee_id: Int?
due_date: Date?
ai_suggested_priority: Int?
created_at: DateTime
}
fitz db diff ahora detecta el cambio:
Generás migration + editás secciones + aplicás:
fitz db new add_estimated_hours_to_tasks
# → ✓ migrations/20260607140000_add_estimated_hours_to_tasks.sql
fitz db diff > /tmp/diff.sql
# Editás migrations/20260607140000_add_estimated_hours_to_tasks.sql
# pegando el SQL en -- UP, escribiendo el -- DOWN.
Contenido del archivo:
-- Migration: add_estimated_hours_to_tasks
-- Created: 2026-06-07T14:00:00Z
-- UP
ALTER TABLE "tasks" ADD COLUMN "estimated_hours" bigint NOT NULL DEFAULT 0;
-- DOWN
ALTER TABLE "tasks" DROP COLUMN "estimated_hours";
Aplicás + rebuild:
fitz db migrate
# → ✓ 1 migration(s) aplicada(s):
# - 20260607140000_add_estimated_hours_to_tasks.sql
docker compose up -d --build app
fitz db status confirma las dos applied:
Migration Estado
---------------------------------------------------------- --------
20260607130000_initial_schema.sql ✓ applied
20260607140000_add_estimated_hours_to_tasks.sql ✓ applied
fitz db diff re-corrido es no-op:
Paso 7 — Rollback¶
Suponiendo que decidiste que estimated_hours no se va a usar:
Verificás contra Postgres:
Atención: tu src/main.fitz todavía declara estimated_hours
en el @table — el fitz db check (próximo paso) lo va a detectar
como drift. Después de un rollback tenés que:
- Borrar el campo del
@tableen código (consistencia con la DB), o - Re-aplicar la migration (
fitz db migrate) si fue un rollback temporal.
Para el cap, asumimos rollback intencional (cambiamos de idea):
borrás estimated_hours: Int = 0 del @table type Task en
src/main.fitz. Rebuild del binario:
Paso 8 — fitz db history¶
Audit log de migrations aplicadas (orden applied_at DESC):
fitz db history
# version applied_at filename
# -------------------- -------------------------------- ----------------------------
# 20260607130000 2026-06-07 13:05:23.456789-00 initial_schema.sql
# 1 migration(s) applied.
Solo initial_schema quedó aplicada (la otra fue rollback). Si más
adelante querés saber qué cambió en producción y cuándo, este
es el comando. Útil cuando algo se rompe a las 3am y necesitás
correlacionar con el último cambio de schema.
Paso 9 — Drift check en CI con GitHub Actions¶
fitz db check corre el diff y devuelve:
- exit 0 si el schema declarado matchea la DB.
- exit 1 + SQL pendiente al stderr si hay drift.
Patrón típico en .github/workflows/ci.yml:
name: ci
on:
pull_request:
branches: [main]
jobs:
schema-drift:
runs-on: ubuntu-latest
services:
db:
image: postgres:16-alpine
env:
POSTGRES_USER: taskhub
POSTGRES_PASSWORD: ci
POSTGRES_DB: taskhub
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://taskhub:ci@localhost:5432/taskhub?sslmode=disable
steps:
- uses: actions/checkout@v4
- name: Install Fitz
run: |
curl -fsSL https://fitzlang.org/install.sh | sh
echo "$HOME/.fitz/bin" >> $GITHUB_PATH
- name: Apply migrations
run: fitz db migrate
- name: Schema drift check
run: fitz db check
Qué pasa si alguien edita @table pero olvida generar la
migration:
fitz db migrateaplica solo las migrations que existen (las viejas) — el schema queda viejo en la DB de CI.fitz db checkcompara@tableen código vs DB → detecta drift.- Exit 1 con el SQL pendiente → el PR queda rojo.
El dev ve el error en la PR antes del merge y corre fitz db diff
+ genera la migration que faltaba.
Validación del cap¶
-
dev-env.shexportaDATABASE_URLcorrectamente. -
psql "$DATABASE_URL" -c "\dt"muestra las 4 tablas +_fitz_migrations. -
fitz db statusmuestrainitial_schema.sqlcomo✓ applied. -
curl http://localhost:8000/api/usersdevuelve[]. - El campo
estimated_hoursse agregó, se rolled-back, y la tablatasksquedó como al principio. -
fitz db diffpost-rollback es no-op (schema sincronizado). -
fitz db historymuestra soloinitial_schema.sqlapplied. - El workflow
.github/workflows/ci.ymlconfitz db checkestá commiteado.
Troubleshooting¶
fitz db diff devuelve SQL aunque ya migrate-é¶
Tu binario del container está stale (build del C1, sin los 4
@table). El fitz db corre desde el host con el src/main.fitz
actual, pero el binario del container tiene la versión vieja.
Esto es OK — fitz db compara @table del fuente vs DB, no el
binario del container.
Si el diff sigue mostrando algo después de fitz db migrate, es
drift real:
- ¿Editaste el
@tabledespués del últimomigrate? Generá nueva migration. - ¿El SQL de la migration tiene un typo (col falta en la migration
pero está en el
@table)? Revisá el.sql.
fitz db migrate falla con connection refused¶
Tu DATABASE_URL apunta a localhost:5432 pero el container db
no está arriba o no expone el puerto.
# Verificá que está arriba.
docker compose ps db
# → taskhub-db Up (healthy) 0.0.0.0:5432->5432/tcp
# Si el port no está expuesto, revisá tu docker-compose.yml — el
# service `db` tiene que tener `ports: ["5432:5432"]`.
curl GET /api/users devuelve 500 Internal Server Error¶
El binario no pudo conectar a la DB. Causas típicas:
DATABASE_URLno resuelve el hostnamedbdesde dentro del container del app (typo en compose).- El service
dbno estáhealthyal momento que el app arranca (revisádepends_on: db: condition: service_healthy). - Las tablas no existen (no corriste
fitz db migrateantes del rebuild).
docker compose logs app | tail -20 muestra el error específico
del binario.
fitz db rollback aborta con no tiene sección -- DOWN¶
El archivo .sql de la migration no tiene -- DOWN (o está vacía).
Si la migration es genuinamente irreversible (ej. DROP de data
crítica), agregá un -- DOWN con RAISE EXCEPTION 'migration X es
irreversible' para documentarlo. Si querés revertir igual a mano:
editá la DB con psql directo y borrá el row de _fitz_migrations
manualmente.
docker compose up -d --build app cuelga compilando¶
fitz build adentro del container hace cargo build --release —
la primera vez tarda 5-10 min porque baja todas las deps de Cargo
(axum, tokio, jsonwebtoken, argon2, etc). Builds posteriores son
cache-friendly (~30s).
Si querés iterar más rápido en desarrollo, podés correr el binario
con fitz run src/main.fitz desde el host (saltea el build de
Rust) pero perdés las optimizaciones runtime del binario nativo.
Es opcional — Docker-first sigue siendo lo recomendado para no
divergir del prod.
El binario del container no encuentra la columna nueva¶
Olvidaste docker compose up -d --build app después del fitz db
migrate. El binario tiene el schema viejo hard-coded. Solucion:
rebuild.
Lo que cubriste¶
- Schema del dominio declarado en 4
@table type(User, Project, Task, Comment) con sus tipos primitivos, nullables y defaults. - Setup del
DATABASE_URLen el shell del dev con un helperdev-env.sh. - Workflow canónico:
new→ editar@table→diff > /tmp/sql→ editar UP/DOWN + agregar FK constraints + indexes →migrate→status. - Verificación con
psqldespués de cada migration. - Rebuild del binario con
docker compose up -d --build apppara que el binario incorpore el schema declarado. - Smoke endpoint
GET /api/usersque prueba end-to-end: cliente → nginx → app → postgres. - Cambio de schema posterior (ALTER TABLE ADD COLUMN) + rollback
- re-aplicación.
- Audit log con
fitz db history. - CI bloqueante con
fitz db check+ GitHub Actions YAML.
El schema de TaskHub está vivo. El binario habla con la DB, los 4 types existen como tablas con FK constraints y indexes, y cualquier cambio futuro pasa por el workflow versionado.
Próximo cap¶
C3 — Auth con RBAC custom: 3 roles apilables.
Vamos a sumar register + login con JWT + Argon2id, declarar el
@auth_provider, y aplicar @authenticated + @requires("admin") /
@requires("owner") / @requires("member") sobre los handlers.
Tests por cada rol verifican que el RBAC funciona — un member no
puede crear un project, un owner no puede ver projects de otros
owners. El RBAC custom apilable es un diferencial fuerte vs
FastAPI/Express donde típicamente se implementa con middleware o
decoradores ad-hoc.
Mientras tanto, commiteá este cap. Tu repo tiene los 4 types
del dominio + las migrations versionadas + CI con drift check.
Cualquier dev que clone el repo + docker compose up -d --build +
source dev-env.sh && fitz db migrate tiene el mismo estado.