M6.C1 — Setup Postgres, db.connect y queries crudas¶
Pre-requisitos: M5.C1 — async.
Todo el módulo M6 usa async fn + .await para hablar con la
DB. Si te quedó alguna duda con Future<T>, repasalo antes de
seguir.
Objetivo: dejar un Postgres real corriendo, conectarse desde
Fitz con db.connect(URL).await, y ejecutar SQL crudo
parametrizado con db.query y db.exec. Es la base sobre la
que se monta el ORM (caps C2-C6).
Por qué importa: el ORM declarativo del próximo cap es
azúcar sobre este driver crudo. Vas a usar el ORM el 95% del
tiempo, pero el 5% restante (CTEs complejas, window functions,
JSON operators no soportados todavía, queries dinámicas
arbitrarias) lo resolvés bajando a db.query. Saber el shape
crudo te ahorra debugging cuando el ORM no cubre exactamente lo
que necesitás. Y conocer el driver es ver el diferencial real
de Fitz: el driver Postgres + el ORM viven adentro del binario,
no son librerías externas que sumás al proyecto.
Cross-link: DB y ORM — guía exhaustiva.
Mapa del cap¶
flowchart LR
A[Postgres corriendo] --> B["URL postgres://user:pass@host:port/db"]
B --> C["db.connect URL .await"]
C -->|Ok| D[DbConn pool interno]
C -->|Err| E["Result Err connection refused"]
D --> F["db.exec SQL params"]
D --> G["db.query SQL params"]
F --> H[Int rows affected]
G --> I["List Map Str Any"]
D --> J[db.close .await]
D --> K[db.is_closed sync Bool]
Por qué Fitz es distinto¶
| Feature | Python + asyncpg | Node + pg | Go + pgx | Rust + tokio-postgres | Fitz |
|---|---|---|---|---|---|
| Setup driver | pip install asyncpg |
npm install pg |
go get jackc/pgx |
cargo add tokio-postgres |
builtin db.connect |
| Sin deps externas | ❌ | ❌ | ❌ | ❌ | ✅ driver puro Fitz/Rust |
| Wire protocol nativo | ✅ pure Python | ⚠ libpq opcional | ✅ pure Go | ✅ pure Rust | ✅ wire v3.0 + SCRAM-SHA-256 |
| TLS rustls (sin OpenSSL system) | ❌ libssl | ❌ libssl | ❌ libssl | ⚠ feature flag | ✅ rustls puro + webpki-roots in-binary |
| URL parsing strict | ⚠ runtime | ⚠ runtime | ⚠ runtime | ⚠ runtime | ✅ combinaciones inválidas abortan parser |
| Pool de conexiones built-in | ⚠ con asyncpg.Pool |
⚠ con pg.Pool |
✅ | ⚠ con bb8/deadpool |
✅ lazy auto |
| Compila a binario standalone | ❌ | ⚠ pkg hack | ✅ | ✅ con deps | ✅ fitz build cero deps |
Validación estática Result<DbConn> |
❌ runtime | ❌ runtime | ⚠ con check | ✅ con ? |
✅ checker + ? propagación |
El diferencial mayor: el driver es parte del binario fitz.
Cuando hacés fitz build, el binario nativo embebe el driver
Postgres entero — wire protocol v3.0, SCRAM-SHA-256, parsers de
tipos OID, TLS con rustls + webpki-roots Mozilla. Cero
apt install libpq, cero cargo add tokio-postgres, cero
runtime de Python con pip install asyncpg. Deploy: copiar el
.exe/ELF/Mach-O al server.
Paso 1 — Levantar un Postgres local¶
Si ya tenés Postgres corriendo (instalación nativa, Docker, managed), saltá al Paso 2. Si no, dos caminos rápidos:
Camino A — Docker (cross-platform, ideal para dev)¶
$ docker run -d --name fitz-pg \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
postgres:16
$ docker logs fitz-pg --tail 5
... database system is ready to accept connections
URL para conectar:
postgres://postgres:secret@localhost:5432/postgres?sslmode=disable
Para parar/borrar:
Camino B — Instalación nativa (Windows / macOS / Linux)¶
- Windows: instalador oficial en postgresql.org/download/windows. Te pregunta password al instalar.
- macOS:
brew install postgresql@16 && brew services start postgresql@16. - Linux (Debian/Ubuntu):
sudo apt install postgresql && sudo systemctl start postgresql.
URL típica con password local:
postgres://postgres:<tu-pass>@localhost:5432/postgres?sslmode=disable.
Crear una DB de trabajo¶
Vas a querer una DB separada del default postgres para no
mezclar con otras herramientas. Desde psql:
O en una sola línea desde shell:
A partir de acá la URL es
postgres://postgres:<pass>@localhost:5432/fitz_curso?sslmode=disable.
Paso 2 — db.connect(url).await: abrir conexión¶
db es un módulo built-in del lenguaje — siempre disponible,
sin import. Su entry point es db.connect(url):
async fn main() -> Result<Str> {
let db = db.connect(
"postgres://postgres:secret@localhost:5432/fitz_curso?sslmode=disable"
).await?
print("conectado!")
return Ok("OK")
}
print(main().await)
Detalles:
- Tipo:
db.connect(url: Str) -> Future<Result<DbConn>>. Result— la conexión puede fallar (host wrong, credenciales inválidas, DB no existe). Patrón canónico:?adentro deasync fnque retornaResult<T>.- Lazy: el
connectarma el pool interno pero no abre socket todavía. La primera conexión TCP se levanta cuando llega el primer query. Esto hace que elconnectsea barato incluso si después no hacés queries. - Pool automático: el pool maneja N conexiones físicas a Postgres bajo el capot. No configurás N — el driver decide según la carga.
Si la URL es inválida, el connect falla al boot:
Patrón típico para apps de larga vida (server HTTP, scheduler
con cron): conectar en el top del main() con ? y dejar que
el server panee si falla — ahí el operador ve el error claro al
arrancar.
Paso 3 — db.exec(sql, params): statements sin rows¶
db.exec es para SQL que NO retorna rows: INSERT, UPDATE,
DELETE sin RETURNING, CREATE TABLE, ALTER, DROP.
Devuelve la cantidad de rows afectadas:
async fn main() -> Result<Str> {
let db = db.connect("postgres://postgres:secret@localhost:5432/fitz_curso?sslmode=disable").await?
// DDL — crear tabla si no existe.
let _ = db.exec(
"CREATE TABLE IF NOT EXISTS users (id bigserial PRIMARY KEY, name text NOT NULL, age bigint NOT NULL)",
[]
).await?
// INSERT con parámetros $1, $2.
let _ = db.exec(
"INSERT INTO users (name, age) VALUES ($1, $2)",
["ada", 35]
).await?
// UPDATE — devuelve N rows afectadas.
let affected = db.exec(
"UPDATE users SET age = age + 1 WHERE name = $1",
["ada"]
).await?
print("filas actualizadas: {affected}")
return Ok("OK")
}
print(main().await)
Tres detalles importantes:
-
Parámetros positional
$1,$2, ... — Postgres usa este sintaxis (no?como MySQL ni:namecomo Oracle). El segundo arg deexec/queryes laList<Any>con valores en orden. -
Strings SQL en una sola línea. El lexer de Fitz NO acepta strings multilínea sin escape. Si el SQL es largo, podés romperlo con concatenación:
let sql = "CREATE TABLE users ("
+ "id bigserial PRIMARY KEY, "
+ "name text NOT NULL, "
+ "age bigint NOT NULL"
+ ")"
let _ = db.exec(sql, []).await?
O guardarlo como let separado de varios bloques.
CREATE TABLE IF NOT EXISTSal boot es el patrón idempotente recomendado para el MVP. En producción avanzada, migraciones reales — pero para empezar, esto cubre el 90%.
Tipos de parámetros aceptados¶
Lo que pasás en la lista de params se auto-coerciona a tipo Postgres:
| Tipo Fitz | Tipo Postgres |
|---|---|
Int |
bigint (int8) |
Float |
double precision (float8) |
Str |
text |
Bool |
bool |
Null |
NULL |
List<Int> |
int8[] |
List<Str> |
text[] |
Map<Str, Any> |
jsonb |
Heterogéneos en la lista van bien — cada elemento se coerce individualmente:
let _ = db.exec(
"INSERT INTO eventos (user_id, name, payload, tags) VALUES ($1, $2, $3, $4)",
[42, "click", {"page": "home"}, ["nav", "ui"]]
).await?
Paso 4 — db.query(sql, params): SELECT con rows¶
db.query es la lectura cruda. Devuelve cada fila como un
Map<Str, Any> con las columnas como keys:
async fn main() -> Result<Str> {
let db = db.connect("postgres://postgres:secret@localhost:5432/fitz_curso?sslmode=disable").await?
let rows = db.query("SELECT id, name, age FROM users WHERE age > $1", [30]).await?
print("rows = {len(rows)}")
// Cada row es Map<Str, Any> — acceso por nombre de columna.
let r = rows[0]
print("primero: {r}")
return Ok("OK")
}
print(main().await)
Salida típica:
Acceso a campos del row — dos formas:
let r = rows[0]
// Acceso directo Map-style (devuelve `Any`).
let raw = r["name"] // Any
// Con anotación de tipo, Fitz coerce el value al tipo declarado.
let name: Str = r["name"] // Str
let age: Int = r["age"] // Int
let user_id: Int = r["user_id"] // Int
// `.get(col)` devuelve `Result<Any>` si querés desempacar con `?`.
let alt = r.get("name")?
El patrón canónico para handlers HTTP que devuelven rows
crudos: declarar el response como List<Map<Str, Any>> y dejar
que el codegen serialize a JSON automático (cada row pasa a {
"col": val, ... }).
Loop sobre filas¶
let rows = db.query("SELECT name, age FROM users ORDER BY age", []).await?
for row in rows {
let name: Str = row["name"]
let age: Int = row["age"]
print("{name} tiene {age}")
}
Paso 5 — Cerrar y chequear estado¶
Dos métodos extra para gestionar el ciclo de vida de la conn:
db.close() -> Future<Result<Null>>¶
Cierra el pool y libera las conexiones físicas a Postgres:
Detalles:
- Idempotente — llamar 2 veces no es error.
- Queries posteriores fallan con
Err("db cerrada"). - Para apps de larga vida (server HTTP), no llames close manualmente — el shutdown del proceso libera el pool. Lo usás típicamente en scripts CLI cortos.
db.is_closed() -> Bool¶
Sync. Devuelve true si la conn fue cerrada con .close().
Útil para checks defensivos:
Paso 6 — TLS strict para Postgres managed (Heroku/RDS/Supabase/Neon)¶
Cuando deployás contra un Postgres administrado (no localhost),
el server exige TLS. La URL acepta el parámetro sslmode
estándar de Postgres:
sslmode |
TLS | Verifica cert | Verifica hostname | Cuándo |
|---|---|---|---|---|
disable |
❌ | — | — | Local dev sin TLS |
require |
✅ | ❌ | ❌ | Dev/staging interno (NO prod — vulnerable a MITM) |
verify-ca |
✅ | ✅ | ❌ | Cert custom con CN/SAN diferente al hostname |
verify-full |
✅ | ✅ | ✅ | Recomendado para producción |
// Supabase / Neon / RDS — TLS verify-full
let db = db.connect(
"postgres://user:pass@db.proyecto.supabase.co:5432/postgres?sslmode=verify-full"
).await?
// Postgres interno con CA corporativa
let db = db.connect(
"postgres://user:pass@db.intra:5432/myapp?sslmode=verify-full&sslrootcert=/etc/ssl/corp-ca.pem"
).await?
Detalles técnicos:
- TLS implementado con
rustlspuro Rust +webpki-roots(Mozilla CA bundle in-binary). Cero deps del sistema — no requierelibssl,SecTransportniSChannel. sslrootcertacepta un PEM custom (CA corporativa). Path relativo al CWD del proceso.- Combinaciones inválidas abortan el parser de URL (no esperan a runtime):
sslmode=disable&sslrootcert=...(cert sin uso).sslmode=require&sslrootcert=...(require no verifica nada).sslrootcert=...sinsslmode=(cert sin contexto).
Out of scope MVP (deuda visible):
sslmode=prefer/allow(negociación dinámica). Vulnerable a TLS-stripping MITM — usádisableoverify-fullexplícito.- Client cert auth (
sslcert/sslkey/sslpassword). Patrón enterprise raramente usado vs TLS server-only.
Paso 7 — Patrón típico en handler HTTP¶
Combinando con M4-M5, así se ve un handler que lee de la DB:
let db = db.connect(
env_or("DATABASE_URL", "postgres://postgres:secret@localhost:5432/fitz_curso?sslmode=disable")
).await
@server(3000)
fn main() => 0
@get("/users")
async fn list_users() -> Result<List<Map<Str, Any>>> {
let conn = match db {
Ok(c) => c,
Err(e) => return Err("db no disponible: {e}"),
}
let rows = conn.query("SELECT id, name, age FROM users ORDER BY id", []).await?
return Ok(rows)
}
@get("/users/count")
async fn count() -> Result<Int> {
let conn = match db {
Ok(c) => c,
Err(e) => return Err("db no disponible: {e}"),
}
let rows = conn.query("SELECT COUNT(*)::bigint AS n FROM users", []).await?
let r = rows[0]
let n: Int = r["n"]
return Ok(n)
}
Lo que pasa:
- La conn se abre una vez al boot del programa (top-level
let db = ...). - Los handlers la reusan en paralelo — el pool interno soporta N requests simultáneas.
env_or("DATABASE_URL", "default")lee la env var en runtime con fallback a la URL local. Convención 12-factor.
Para el ORM declarativo (caps C2+), no vas a escribir SQL crudo
así — vas a hacer User.where(...).all(db).await?. Pero el
shape de "abrir db top-level, pasar a cada handler" es el mismo.
Validación¶
Probá un programa minimal end-to-end:
// hello-db.fitz
async fn main() -> Result<Str> {
let db = db.connect("postgres://postgres:secret@localhost:5432/fitz_curso?sslmode=disable").await?
let _ = db.exec("CREATE TABLE IF NOT EXISTS hello (id bigserial PRIMARY KEY, msg text NOT NULL)", []).await?
let _ = db.exec("INSERT INTO hello (msg) VALUES ($1)", ["primer row desde Fitz"]).await?
let rows = db.query("SELECT id, msg FROM hello ORDER BY id DESC LIMIT 1", []).await?
let r = rows[0]
let msg: Str = r["msg"]
print("último row: {msg}")
return Ok("OK")
}
print(main().await)
Si esto anda, el setup está OK. Si no:
- Postgres está corriendo (
psql -U postgres -h localhost -c "SELECT 1;"). - La URL tiene el password correcto.
- La DB
fitz_cursoexiste (CREATE DATABASE fitz_curso;). - El
sslmodematchea con la config del server (disablepara local;verify-fullpara managed). - El programa está adentro de un
async fn main()que retornaResult<...>—?no funciona top-level.
Troubleshooting¶
Err("connection refused (...)")¶
El driver no pudo abrir socket al host:port. Verificá:
- Postgres está corriendo —
psql -U postgres -h localhost -c "SELECT 1;". - El puerto correcto. Default 5432, pero containers a veces exponen otros.
- Si es Docker, el container está running:
docker ps | grep postgres.
Err("password authentication failed for user 'X'")¶
El user/password de la URL no matchea con la config de Postgres. Cuidado con:
- Caracteres especiales en el password (URL-encode
@,:,/como%40,%3A,%2F). - Postgres
pg_hba.confcon auth method distinto (peersolo desde el user del SO, etc.).
Err("database 'X' does not exist")¶
Le pasaste un nombre de DB que no existe. Creala con
CREATE DATABASE X; o cambiá la URL.
Err("relation 'X' does not exist") al hacer query¶
La tabla no existe. CREATE TABLE IF NOT EXISTS X (...) al boot
es el patrón idempotente — agregalo antes del primer query.
Error del lexer "String sin cerrar"¶
Tenés un string SQL con saltos de línea sin escape. Fitz NO soporta strings multilínea raw — opciones:
- SQL en una sola línea (verbose pero claro).
- Concatenación con
+: - Escape
\nadentro del string (raro para SQL — el lexer no parseará el\ncomo newline real, lo dejará literal).
db.query retorna un row pero r["col"] es Any (no Int/Str)¶
Esperado. r["col"] siempre tipa Any en el checker. Con
anotación, Fitz coerce el value:
Sin anotación, podés operar en runtime (r["age"] + 1 funciona)
pero el checker no te ayuda con typos del nombre de la columna.
Connect lento en el primer query (no en connect)¶
Esperado. db.connect es lazy — solo arma el pool. La
primera conexión TCP + handshake SCRAM se levanta cuando llega
el primer query/exec. Si querés "calentar" el pool, hacé
un db.query("SELECT 1", []).await? justo después del connect.
Err("SSL connection error: ...") con managed Postgres¶
Cuando deployás contra Heroku/RDS/Supabase/Neon, exigen TLS.
Cambiá la URL a sslmode=verify-full:
Si el server tiene cert auto-firmado, podés bajar a require
(NO seguro para prod) o pasar sslrootcert=path/to/ca.pem.
fitz build falla buscando libpq¶
Esto NO debería pasar — el driver de Fitz no linkea contra libpq. Si ves el error, abrir issue: es un bug del codegen.
Lo que sigue¶
Llegaste al final del cap. Lo que cubriste:
- Levantar Postgres local con Docker o instalación nativa.
- Conectarse con
db.connect(url).await?— URL formato estándar Postgres + pool lazy automático. db.exec(sql, params)para statements sin rows (INSERT/UPDATE/ DELETE/DDL) — devuelve rows afectadas.db.query(sql, params)para SELECT — devuelveList<Map<Str, Any>>con acceso por nombre de columna.- Parámetros positional
$1,$2con auto-coerción de tipos Fitz → Postgres (incluyeList<scalar>→T[]yMap<Str, Any>→jsonb). db.close()+db.is_closed()para gestión de ciclo de vida.- TLS strict con
sslmode=verify-fullpara Postgres managed, implementado conrustls+ webpki-roots sin deps del sistema.
Próximo cap: M6.C2 — Mapping con @table, @primary y
métodos del ORM. Vamos a montar
el ORM declarativo sobre type: @table("users") type User
{...} + User.all(db)/first/where(closure)/count. El
SQL deja de ser strings y empieza a ser expresiones tipadas
que el checker valida en compile-time.