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#
- Crear tablas, insertar y consultar desde un programa de Sofía.
- Por qué los valores van entre
{ }y qué pasa si intentas pegarlos al SQL. - Leer filas en registros tipados y valores sueltos.
- Usar
transaccion { }para reglas de cupo que resisten cien peticiones a la vez. - Qué hay en esta versión de SofiaDB, qué falta y cuándo conviene seguir con archivos.
1. Qué es y cuándo usarla#
| Pieza | Estado | Para qué |
|---|---|---|
SofiaDB embebida (permiso datos) | 🟢 Existe | Datos con estructura: tablas, consultas, totales, reglas que cruzan filas |
archivos.* en la carpeta privada | 🟢 Existe | Documentos, configuraciones, exportaciones, archivos que el usuario ve |
archivos.escribir_si | 🟢 Existe | Cambiar 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 | 🟢 Existe | Consultas 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ón | Devuelve | Para |
|---|---|---|
datos.ejecutar(sql) | entero | CREATE, 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 · texto | La primera columna de la primera fila: count(*), sum(…), un nombre suelto. Sin filas, 0 o "" |
datos.copiar(nombre) | entero | Copia 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:
| Columna | Campo | Nota |
|---|---|---|
INTEGER, TIMESTAMPTZ | entero | Un instante son milisegundos desde 1970, como reloj.ahora() |
TEXT | texto | |
NUMERIC(p,s) | texto | Decimal exacto, sin redondeos: "25.50". Para dinero con centavos |
BOOLEAN | logico | |
BYTEA | texto | En 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:
- Dentro no se puede
devolver, ni salir conromperocontinuar: guarda el resultado en una variable y devuélvelo después, como arriba. - Los bloques no se anidan.
- Si una sentencia falla dentro y la recoges con un
intentarinterior, la transacción ya no admite más sentencias: se deshace al terminar el bloque. max(…)ymin(…)de ninguna fila son un fallo (no hayNULL);sum(…)de ninguna fila da0. En el ejemplo, la tabla nunca está vacía cuando se llama aregistrar.
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)
}
- Exacta: la copia tiene exactamente lo confirmado en el instante en que empieza; lo que se confirma después no entra. Las demás peticiones siguen leyendo y guardando mientras se copia, sin esperar.
- Entera o nada: se escribe aparte, se lleva al disco, se abre y se verifica completa; solo entonces aparece con su nombre. Si se corta la luz o falta espacio, el archivo no existe; nunca queda a medias, y la base de la app no se toca.
- Dentro de la carpeta privada:
nombresigue las reglas dearchivos.*(sin carpetas ni punto inicial) y no puede existir ya. La copia cuenta para eldiscode la app: si no cabe, falla antes de escribir. - Fuera de
transaccion { }: no compila dentro del bloque (si el bloque se repitiera, copiaría otra vez). Lo que la app tenga sin confirmar no entra en la copia. - El resultado es una base normal: para restaurarla, con la app detenida, se pone en lugar de
.sofiadb/base.sdb(borrando su diariobase.sdb-wal).
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:
| Grupo | Qué hay |
|---|---|
| Tablas | CREATE TABLE [IF NOT EXISTS] con PRIMARY KEY, NOT NULL, DEFAULT, UNIQUE, CHECK; CREATE [UNIQUE] INDEX; DROP TABLE; ALTER TABLE … ADD COLUMN … DEFAULT … |
| Cambios | INSERT (varias filas), INSERT OR REPLACE, INSERT OR IGNORE, UPDATE, DELETE; una clave INTEGER que omites en el INSERT toma sola la siguiente (autoincremental) |
| Consultas | SELECT 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áneas | REFERENCES 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 |
| Tipos | INTEGER, 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#
| Error | Consecuencia | Corrección |
|---|---|---|
Armar el SQL con + o en una variable | No compila | Texto literal con los valores entre { } |
Comillas alrededor de {valor} | No compila | WHERE nombre = {nombre}: el parámetro ya es un texto |
Leer fuera del transaccion { } y escribir dentro | La regla se rompe con peticiones simultáneas | Leer, decidir y escribir dentro del bloque |
devolver dentro del bloque | No compila | Guarda el resultado en una variable y devuélvelo después |
| Un campo del registro sin columna con su nombre | Fallo «la consulta no devuelve la columna» | AS nombre_del_campo |
max(id) en una tabla vacía | Fallo: no hay NULL | Comprueba antes con count(*), o usa otra clave |
Dinero con decimales en INTEGER | Se pierden los centavos | NUMERIC(12,2) y el valor como texto ({"25.50"}) |
Un SELECT sin LIMIT sobre una tabla enorme | La consulta falla al pasar de 64 MiB | LIMIT y OFFSET |
red, archivos, consola o datos.copiar dentro del transaccion { } | No compila: el bloque puede repetirse | Antes o después del bloque |
Respaldar copiando .sofiadb con la app en marcha | Copia dañada | datos.copiar (o copiar con la app detenida) |
Una transaccion muy larga en una app web | Choca más con las demás y se repite más; pasados 30 s falla | Bloques cortos: solo lo que debe ir junto |
12. Próximos pasos#
- [ ] Ejecutar
gastos.sofdos veces y cambiar un presupuesto con unUPDATEpara ver cambiar el informe. - [ ] Correr
probar-talleres.sh, sacar la regla deltransaccion { }y ver cómo se pasa del cupo. - [ ] Llevar el inventario de
ejemplos/almacenamiento/a una tabla con una transacción. - [ ] Leer la referencia de SQL, SofiaDB desde el lenguaje y la ficha de SofiaDB.
← Anterior: 20 · Microservicios · Índice de la guía · Siguiente: 22 · Despliegue en un servidor →