---
slug: 44-db-api
title: Tu DB como API REST
description: Exponé una base de datos como API REST genérica por introspección — sql_tables() + whitelist, roles, y row-level security como una lambda por tabla. Un patrón, hoy, sin features nuevas.
example_ids: [db-api]
---

# Tu DB como API REST

**[Armá una API REST](/es/0.6.x/43-build-api)** muestra el CRUD escrito a mano. Esta página es la versión **genérica**: apuntá Synsema a una base y serví cada tabla whitelisteada por las mismas tres rutas — descubrimiento por **introspección**, acceso por **roles**, y filtrado por fila con una **lambda de row-level security**. Sin framework, sin codegen, sin features nuevas del lenguaje.

```synsema
-- Doc example: expose your DB as a REST resource — introspection + whitelist + RLS.
-- The pattern is pure userland: sql_tables() discovers the schema, EXPOSED whitelists
-- what's public, and row-level security is one lambda per table. The serve routes on
-- top are in the page (44-db-api); everything testable without a server lives here.
intent: "doc example: DB-as-API — introspection, whitelist, row-level security"
require db(":memory:")

db_open(":memory:")
sql_exec("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)")
sql_exec("CREATE TABLE carts (id INTEGER PRIMARY KEY, customer_phone TEXT, status TEXT)")
sql_exec("CREATE TABLE internal_audit (id INTEGER PRIMARY KEY, note TEXT)")
sql_exec("INSERT INTO products (name, price) VALUES ('Notebook Aurora 14', 899)")
sql_exec("INSERT INTO carts (customer_phone, status) VALUES ('+549115555', 'open')")
sql_exec("INSERT INTO carts (customer_phone, status) VALUES ('+549119999', 'open')")
sql_exec("INSERT INTO internal_audit (note) VALUES ('never expose me')")

-- The whitelist IS the API surface: introspection finds tables, YOU decide what's public.
let EXPOSED be where(sql_tables(), (t) => contains(["products", "carts"], t))

-- Row-level security: one lambda per table, (row, user) => bool. Discipline, not
-- enforcement — a forgotten rls_allow is a leak, so keep ALL reads behind table_rows.
task rls_allow(t, row, user)
    when t == "carts"
        give user.role == "admin" or row.customer_phone == user.phone
    give true

task table_rows(t, user)
    when not contains(EXPOSED, t)
        give nothing
    let rows be sql("SELECT * FROM " + t)
    give where(rows, (r) => rls_allow(t, r, user))

let ADMIN be {"user": "root", "role": "admin", "phone": ""}
let MARIA be {"user": "maria", "role": "customer", "phone": "+549115555"}

print("exposed tables: " + text(EXPOSED))

test "introspection + whitelist: only public tables are exposed"
    assert(contains(EXPOSED, "products"))
    assert(contains(EXPOSED, "carts"))
    assert(not contains(EXPOSED, "internal_audit"))

test "a non-exposed table is a 404, not an error"
    assert_eq(table_rows("internal_audit", ADMIN), nothing)
    assert_eq(table_rows("no_such_table", ADMIN), nothing)

test "RLS: admin sees every cart, a customer only their own"
    assert_eq(length(table_rows("carts", ADMIN)), 2)
    let mine be table_rows("carts", MARIA)
    assert_eq(length(mine), 1)
    assert_eq((mine[0])["customer_phone"], "+549115555")

test "RLS: tables without a rule are public to any authenticated user"
    assert_eq(length(table_rows("products", MARIA)), 1)
```

## Las tres piezas

1. **Introspección + whitelist.** `sql_tables()` lista lo que existe; la whitelist decide qué es público. La whitelist ES la superficie de la API — las tablas internas simplemente no existen para afuera:

```synsema
let EXPOSED be where(sql_tables(), (t) => contains(["products", "carts"], t))
```

2. **Row-level security = una lambda por tabla.** `(row, user) => bool`, aplicada a cada lectura. Un admin ve todo; un cliente solo sus filas:

```synsema
task rls_allow(t, row, user)
    when t == "carts"
        give user.role == "admin" or row.customer_phone == user.phone
    give true
```

3. **Una sola puerta para toda lectura.** `table_rows(t, user)` chequea la whitelist, consulta, filtra por RLS — y todas las rutas la llaman. Jamás consultes por al lado.

## El wiring de serve

Las rutas genéricas van encima (el auth es el bearer nativo de `serve` — ver **[Serve](/es/0.6.x/40-serve)**):

```synsema
serve on 8080
    auth with check_token

    route "GET /api" requires auth
        give EXPOSED                                  -- superficie descubrible

    route "GET /api/:table" requires auth
        let rows be table_rows(params.table, user of request)
        give when rows == nothing then not_found("no such resource") otherwise rows

    route "GET /api/:table/:id" requires auth
        let rows be table_rows(params.table, user of request)
        when rows == nothing
            give not_found("no such resource")
        let hit be find_first(rows, (r) => r.id == number(params.id))
        give when hit == nothing then not_found("no such row") otherwise hit
```

Las escrituras quedan explícitas — una ruta `POST` por recurso escribible, gateada por rol (`when not contains(WRITERS, u.role)` → `fail(403, ...)`), así el write-path valida con `expect body {...}` como cualquier API a mano.

## Esto funciona en Postgres y MySQL, sin cambios

El ejemplo usa SQLite `:memory:` para correr offline, pero nada del patrón es específico de SQLite: `sql()`/`sql_exec()` se rutean por motor, y `sql_tables()` introspecciona el catálogo propio de cada uno (`sqlite_master` / `pg_catalog.pg_tables` / `information_schema.tables`). Para servir tu Postgres, solo cambia la conexión:

```synsema
require db("postgres://localhost/appdb")     -- scope = URL canónica (sin credenciales)
db_open("postgres://user:pw@localhost:5432/appdb")
```

Todo lo demás — whitelist, RLS, rutas — es idéntico. (Mongo y Redis son APIs distintas, no SQL: para Mongo armá el mismo patrón sobre `mongo_collections()` + `mongo_find`; ver **[SQL, Mongo y Redis](/es/0.6.x/33-sql-mongo-redis)**.) Notá que el patrón usa tus **datos de aplicación** (`require db`) — no toca la memoria declarada del agente (`require memory`), que es un plano separado (ver **[Memoria y estado](/es/0.6.x/61-memory)**).

> **Advertencia honesta: esto es disciplina, no enforcement.** El patrón es hermético solo mientras TODA lectura pase por `table_rows` — una ruta que consulte la tabla directo es una fuga. Mantené la puerta RLS en una sola task y revisá cualquier `sql(...)` nuevo en rutas.

La misma declaración whitelist + RLS alimenta también la versión MCP de esta API — ver **[Tu DB como tools MCP](/es/0.6.x/45-db-mcp)**.
