21 · Guardar datos con SofiaDB#

Parte 3 · Servidores y microservicios · Requiere el capítulo 13 · Código en ejemplos/sofiadb/ y ejemplos/almacenamiento/

Consejo

En corto: SofiaDB es la base de datos SQL de Sofía. Tu app declara permiso datos y recibe una base privada que ninguna otra app puede abrir. Creas tablas, insertas y consultas con SQL estándar; los valores van siempre entre { } y viajan aparte como parámetros, así que la inyección SQL no compila. Un bloque transaccion { } guarda todo junto o nada, y lo confirmado sobrevive a un corte de luz. Para archivos sueltos sigue estando archivos.escribir_si.

Lo que vas a aprender#

1. Qué es y cuándo usarla#

PiezaEstadoPara qué
SofiaDB embebida (permiso datos)🟢 ExisteDatos con estructura: tablas, consultas, totales, reglas que cruzan filas
archivos.* en la carpeta privada🟢 ExisteDocumentos, configuraciones, exportaciones, archivos que el usuario ve
archivos.escribir_si🟢 ExisteCambiar un archivo sin pisarse, cuando no hace falta una base
SofiaDB con índices, JOIN, varios escritores a la vez, copia en caliente y cifrado por página🟢 ExisteConsultas entre tablas y más concurrencia (ficha)

La regla práctica: si te encuentras escribiendo un formato de líneas con tabuladores, escapando textos y recorriendo todo el archivo para contar algo, lo que quieres es una tabla.

2. Primer contacto#

Consejo

Para una app web que ya guarda en SofiaDB, con su tabla, su permiso datos y sus pruebas, usa sofia nueva mis-notas --tipo web-datos y parte desde ahí.

Declara el permiso y crea la tabla al empezar. CREATE TABLE IF NOT EXISTS no hace nada si ya existe, así que se puede llamar en cada ejecución:

app "Gastos" id "guia-gastos" version "1.0"
permiso consola, datos

fn preparar() {
    datos.ejecutar("CREATE TABLE IF NOT EXISTS gasto (id INTEGER PRIMARY KEY, categoria TEXT NOT NULL, detalle TEXT NOT NULL, monto INTEGER NOT NULL, pagado BOOLEAN NOT NULL DEFAULT false)")
}

La base vive en la carpeta privada de la app, en ~/.sofia/datos/<id>/.sofiadb/base.sdb (más su diario de escritura). Esa subcarpeta empieza por punto: archivos.* no puede leerla ni borrarla, y ninguna otra app tiene cómo abrirla. Su tamaño máximo es el disco de la app (256 MiB si no declaras otro).

Las funciones y el bloque que añade el permiso:

FunciónDevuelvePara
datos.ejecutar(sql)enteroCREATE, INSERT, UPDATE, DELETE, ALTER, DROP. Devuelve las filas afectadas
datos.consultar(Registro, sql)lista<Registro>Un SELECT con una fila por registro
datos.entero(sql) · datos.texto(sql)entero · textoLa primera columna de la primera fila: count(*), sum(…), un nombre suelto. Sin filas, 0 o ""
datos.copiar(nombre)enteroCopia de seguridad de la base, sin detener la app, en un archivo nuevo de la carpeta privada. Devuelve sus bytes (§8)
transaccion { … }Todo lo del bloque se guarda junto, o nada si algo falla dentro

3. Insertar: los valores van entre { }#

datos.ejecutar($"INSERT INTO gasto (id, categoria, detalle, monto) VALUES ({id}, {categoria}, {detalle}, {monto})")

Parece un texto con formato, pero no lo es del todo. El compilador separa el SQL de los valores: el SQL queda como … VALUES ($1, $2, $3, $4) y cada {…} viaja aparte, con su tipo, como parámetro. Ningún valor se pega jamás dentro del SQL, así que un detalle como Pan'); DROP TABLE gasto; -- se guarda tal cual, como un texto más.

Lo que no compila, a propósito:

datos.ejecutar("DELETE FROM gasto WHERE id = " + texto.de(id))   // SQL armado con +
let sql = "DELETE FROM gasto"
datos.ejecutar(sql)                                               // SQL en una variable
datos.ejecutar($"DELETE FROM gasto WHERE detalle = '{detalle}'")  // comillas alrededor de { }

Cada uno da un error que explica cómo escribirlo bien. La inyección SQL no depende de que te acuerdes de nada: es imposible por construcción.

Entre llaves va un entero, un texto o un logico, o cualquier expresión que dé uno de ellos ({n + 1}, {"%" + palabra + "%"}). Un real no entra directo: pásalo como texto con texto.de_real(x) a una columna NUMERIC. Un texto se convierte al tipo de la columna: {"25.50"} entra en una columna NUMERIC, {"2026-09-28T09:00:00Z"} en una TIMESTAMPTZ.

4. Consultar#

Para varias filas, declara un registro con los campos que quieres y pásalo a datos.consultar. Cada campo se llena con la columna del mismo nombre; usa AS para renombrar:

tipo Resumen {
    categoria: texto
    total: entero
    cuantos: entero
}

let filas = datos.consultar(Resumen, "SELECT categoria, sum(monto) AS total, count(*) AS cuantos FROM gasto GROUP BY categoria ORDER BY categoria")
para r en filas {
    consola.escribir_linea($"  {r.categoria}: ${r.total} ({r.cuantos})")
}

Para un valor suelto, datos.entero y datos.texto:

let minimo = 30000
let grandes = datos.entero($"SELECT count(*) FROM gasto WHERE monto >= {minimo}")

Cómo pasan los tipos de la base al lenguaje:

ColumnaCampoNota
INTEGER, TIMESTAMPTZenteroUn instante son milisegundos desde 1970, como reloj.ahora()
TEXTtexto
NUMERIC(p,s)textoDecimal exacto, sin redondeos: "25.50". Para dinero con centavos
BOOLEANlogico
BYTEAtextoEn base64

Si una columna no encaja con el campo (un TEXT en un campo entero, o una columna que la consulta no devuelve), es un fallo recuperable que dice cuál es.

Consejo

Los pesos chilenos no tienen decimales: guárdalos como INTEGER y los sumas y comparas en el programa como enteros. Usa NUMERIC cuando haya centavos o fracciones (dólares, UF).

5. Transacciones: todo o nada#

Sin transaccion, cada datos.* se guarda por sí solo al terminar. Con el bloque, todo lo de dentro se confirma junto al salir; si algo dentro llama a fallar (o falla el motor, o una función que llamaste), se deshace todo y el fallo sigue hacia fuera con el mismo mensaje.

Si otra transacción confirmó antes un cambio en algo que el bloque leyó, el bloque se repite solo desde el principio (hasta 5 veces, con una espera al azar de 1 a 32 ms); si sigue chocando, falla con un mensaje que empieza por CONFLICTO_SERIALIZACION. Como puede repetirse, dentro del bloque solo va datos y cálculo: llamar a red, archivos, consola u otro permiso (también desde una función que llames ahí) no compila; hazlo antes o después del bloque. Las variables de fuera que cambies dentro no se deshacen al repetirse.

Los errores de las restricciones empiezan por un código estable que la app puede reconocer: UNICO_VIOLADO:<indice> (también <tabla>_pkey para la clave primaria), FORANEA_VIOLADA:<restriccion>, COMPROBAR_VIOLADO:<restriccion> y CONFLICTO_SERIALIZACION.

De gastos.sof: un gasto que pasa el presupuesto no queda registrado, aunque el INSERT ya se hizo:

fn registrar(categoria: texto, detalle: texto, monto: entero) -> texto {
    var resultado = "registrado"
    intentar {
        transaccion {
            let id = datos.entero("SELECT max(id) FROM gasto") + 1
            datos.ejecutar($"INSERT INTO gasto (id, categoria, detalle, monto) VALUES ({id}, {categoria}, {detalle}, {monto})")
            let gastado = datos.entero($"SELECT sum(monto) FROM gasto WHERE categoria = {categoria}")
            let tope = datos.entero($"SELECT tope FROM presupuesto WHERE categoria = {categoria}")
            si gastado > tope {
                fallar($"SOBRE_PRESUPUESTO: {categoria} llegaría a {gastado} de {tope}")
            }
        }
    } si falla e {
        resultado = e
    }
    devolver resultado
}

Reglas del bloque:

6. El ejemplo completo#

gastos.sof junta todo lo anterior: crea dos tablas, carga presupuestos y gastos la primera vez, rechaza lo que pasa del presupuesto, marca pagados con un UPDATE, recoge un error de clave repetida e imprime un informe.

sofiac compilar gastos.sof
sofia gastos.sofia

La primera ejecución:

Supermercado: registrado
Bencina: registrado
Feria: registrado
Taxi: SOBRE_PRESUPUESTO: transporte llegaría a 70000 de 60000
Raro: registrado
Pagados de comida: 3
Error recogido: restricción violada: clave primaria repetida en presupuesto: ya hay una fila con categoria = comida
Gastos:
  #1 casa: Arriendo, $420000 (pagado)
  #2 comida: Supermercado, $85000 (pagado)
  #3 transporte: Bencina, $45000 (pendiente)
  #4 comida: Feria del sábado, $32000 (pagado)
  #5 comida: Pan'); DROP TABLE gasto; --, $2500 (pagado)
Por categoría:
  casa: $420000 (1)
  comida: $119500 (3)
  transporte: $45000 (1)
Gastos de $30000 o más: 4
Busca «sábado»: Feria del sábado

El taxi no quedó: su INSERT se deshizo junto con el fallo, y por eso el gasto siguiente volvió a recibir el número 5. El detalle con SQL dentro se guardó como un texto cualquiera.

La segunda encuentra la base tal como quedó:

La base ya tenía datos: no se carga nada.
Error recogido: restricción violada: clave primaria repetida en presupuesto: ya hay una fila con categoria = comida
Gastos:
  #1 casa: Arriendo, $420000 (pagado)
  …

Nota

Para probar sin tocar tu carpeta de usuario, apunta la plataforma a una carpeta de ensayo con la variable SOFIA_HOME (por ejemplo SOFIA_HOME=/tmp/ensayo sofia gastos.sofia); la base queda en /tmp/ensayo/datos/guia-gastos/. Bórrala para empezar de cero.

7. En una app web: cien peticiones, un cupo#

Atrio atiende cada petición en una instancia nueva y varias a la vez. Todas las instancias de una app comparten su base y no se esperan: cada transaccion { } trabaja sobre su propia foto de la base. Al confirmar, la plataforma comprueba que nadie cambió lo que el bloque leyó; si alguien lo hizo, el bloque se repite solo, ya viendo lo nuevo. El resultado es el mismo que si las peticiones hubieran pasado de una en una, así que una regla de cupo escrita dentro del bloque se cumple siempre.

De talleres.sof:

fn inscribir(taller: texto, persona: texto) -> texto {
    var resultado = "INSCRITO"
    intentar {
        transaccion {
            si datos.entero($"SELECT count(*) FROM taller WHERE nombre = {taller}") == 0 {
                fallar("NO_EXISTE")
            }
            let cupo = datos.entero($"SELECT cupo FROM taller WHERE nombre = {taller}")
            let usados = datos.entero($"SELECT count(*) FROM inscripcion WHERE taller = {taller}")
            si usados >= cupo {
                fallar("SIN_CUPO")
            }
            datos.ejecutar($"INSERT INTO inscripcion VALUES ({taller}, {persona})")
        }
    } si falla e {
        resultado = e
    }
    devolver resultado
}

Compáralo con el inventario de archivos de la sección 10: no escribes el ciclo de reintentos ni comparas el archivo entero. Leer, decidir y escribir van dentro del bloque, y ya.

probar-talleres.sh crea un taller con 3 cupos y lanza cien inscripciones en paralelo contra la app de verdad, en una carpeta de usuario aislada:

$ bash probar-talleres.sh
estados:       3 200;     97 409
inscritas: 3 (esperado 3); sin cupo: 97 (esperado 97)
talleres: [{"nombre":"ceramica","cupo":3,"inscritos":3}]

Entran exactamente tres. Si sacas las líneas del transaccion { } fuera del bloque, dos peticiones pueden contar los mismos inscritos a la vez y pasarse del cupo.

8. Qué pasa si se corta la luz#

Cuando datos.ejecutar o un transaccion { } termina sin fallo, lo guardado ya está en el disco: la plataforma escribe primero en un diario y le pide al sistema operativo que lo lleve al disco físico antes de seguir. Si se corta la luz a mitad de una transacción, al abrir la base esta vuelve al último estado confirmado: nunca queda media transacción, ni una base dañada.

No es una promesa: las pruebas de SofiaDB cortan la corriente en un disco simulado en cada escritura y sincronización posible, pierden o rompen lo que no estaba sincronizado, y comprueban que la base abre, pasa su verificación y tiene exactamente lo confirmado. Cada página lleva una suma de control: un archivo dañado por fuera se detecta, no se interpreta.

Copias de seguridad sin detener la app#

Copiar los archivos de la base mientras la app escribe no sirve: la base y su diario cambian durante la copia y lo copiado puede quedar dañado (en las pruebas de estrés, 5 de cada 10 copias así). Para eso está datos.copiar:

fn respaldar() -> entero {
    let nombre = $"respaldo-{reloj.ahora()}.sdb"
    return datos.copiar(nombre)
}

Desde la terminal, sofia datos copiar <base.sdb|id de la app> <destino.sdb> hace lo mismo con una base que ningún proceso tiene abierta. Si la app está sirviendo (Atrio tiene la base), la orden lo dice y la copia se pide a la app, con datos.copiar. El capítulo 23 muestra cómo encajarla en los respaldos cifrados.

9. El SQL de esta versión#

SofiaDB entiende un subconjunto del SQL estándar, en inglés:

GrupoQué hay
TablasCREATE TABLE [IF NOT EXISTS] con PRIMARY KEY, NOT NULL, DEFAULT, UNIQUE, CHECK; CREATE [UNIQUE] INDEX; DROP TABLE; ALTER TABLE … ADD COLUMN … DEFAULT …
CambiosINSERT (varias filas), INSERT OR REPLACE, INSERT OR IGNORE, UPDATE, DELETE; una clave INTEGER que omites en el INSERT toma sola la siguiente (autoincremental)
ConsultasSELECT con WHERE, ORDER BY, LIMIT/OFFSET, GROUP BY/HAVING; count, sum, min, max; [INNER] JOIN y LEFT [OUTER] JOIN … ON con alias y t.columna; subconsultas escalares, IN, NOT IN, EXISTS, NOT EXISTS y tablas derivadas en el FROM
Claves foráneasREFERENCES t(c) y FOREIGN KEY (…) REFERENCES t(…) con RESTRICT o NO ACTION (sin CASCADE); error FORANEA_VIOLADA:<restricción>; la hija recibe sola un índice por la foránea
Operadores= <> < <= > >= AND OR NOT BETWEEN IN LIKE || + - * / %; lower, upper, length, abs, trim, substr, encode, decode
TiposINTEGER, TEXT, BOOLEAN, BYTEA, NUMERIC(p,s), TIMESTAMPTZ (o en español: ENTERO, TEXTO, LOGICO, BYTES, DECIMAL, INSTANTE)

Sin NULL, como el lenguaje: toda columna tiene valor. Las columnas que añades con ALTER TABLE necesitan DEFAULT.

Con varias tablas hay dos diferencias con el SQL habitual, por no tener NULL: un LEFT JOIN sin pareja rellena las columnas de la derecha con el vacío del tipo ('', 0, false) y una subconsulta escalar sin filas devuelve ese mismo vacío. Además, datos.consultar rechaza un resultado con dos columnas del mismo nombre (típico de SELECT * sobre un JOIN): ponles AS. EXPLAIN muestra la estrategia de cada JOIN (índice, HASH o bucle anidado).

Lo que aún no hay: ON DELETE CASCADE. Todo el SQL, sentencia por sentencia y con lo que se rechaza, está en la referencia de SQL; las funciones, los tipos y los límites, en SofiaDB desde el lenguaje.

Cifrado#

Una base puede guardarse cifrada página por página, con una clave maestra; sin la clave correcta no se abre. Los detalles están en la ficha de SofiaDB, y las órdenes para cifrar, rotar claves, copiar y dar acceso remoto, en sofia datos.

10. Archivos y archivos.escribir_si#

Para lo que no es una tabla (un documento, una configuración, un archivo que exportas), sigue estando la carpeta privada con archivos.*, y archivos.escribir_si(nombre, anterior, nuevo) para cambiar un archivo sin que dos peticiones se pisen: escribe solo si el archivo sigue igual a lo que leíste.

fn sumar_una() -> entero {
    para intento en 0..200 {
        let antes = archivos.leer("visitas.txt")
        let nuevo = entero.de(antes) + 1
        si archivos.escribir_si("visitas.txt", antes, texto.de(nuevo)) {
            devolver nuevo
        }
    }
    fallar("OCUPADO: no se pudo contar la visita")
    devolver 0
}

Los ejemplos de ejemplos/almacenamiento/ muestran el patrón completo: un contador, un inventario con su prueba de cien reservas (probar-inventario.sh), una bitácora que solo crece y un archivo por cuenta. Funcionan, pero cada regla necesita su ciclo de reintentos y su formato de archivo. Con SofiaDB, el inventario es una tabla y la regla va en un transaccion { }.

Los patrones entre servicios del capítulo 20 valen igual con una base: un dueño por dato (cada servicio tiene su propia base, y los demás le piden por su API), ids opacos entre servicios y nada de transacciones que crucen servicios. Una transacción de SofiaDB cubre la base de una sola app.

11. Errores típicos#

ErrorConsecuenciaCorrección
Armar el SQL con + o en una variableNo compilaTexto literal con los valores entre { }
Comillas alrededor de {valor}No compilaWHERE nombre = {nombre}: el parámetro ya es un texto
Leer fuera del transaccion { } y escribir dentroLa regla se rompe con peticiones simultáneasLeer, decidir y escribir dentro del bloque
devolver dentro del bloqueNo compilaGuarda el resultado en una variable y devuélvelo después
Un campo del registro sin columna con su nombreFallo «la consulta no devuelve la columna»AS nombre_del_campo
max(id) en una tabla vacíaFallo: no hay NULLComprueba antes con count(*), o usa otra clave
Dinero con decimales en INTEGERSe pierden los centavosNUMERIC(12,2) y el valor como texto ({"25.50"})
Un SELECT sin LIMIT sobre una tabla enormeLa consulta falla al pasar de 64 MiBLIMIT y OFFSET
red, archivos, consola o datos.copiar dentro del transaccion { }No compila: el bloque puede repetirseAntes o después del bloque
Respaldar copiando .sofiadb con la app en marchaCopia dañadadatos.copiar (o copiar con la app detenida)
Una transaccion muy larga en una app webChoca más con las demás y se repite más; pasados 30 s fallaBloques cortos: solo lo que debe ir junto

12. Próximos pasos#


← Anterior: 20 · Microservicios · Índice de la guía · Siguiente: 22 · Despliegue en un servidor →