Synsema docsENES

Tu DB como API REST

Armá una API REST 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.

db-api.syn
-- 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:

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:

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):

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:

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.) 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).

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.