Back to flin
flin

Persistencia en el navegador

Cómo hicimos que FlinDB funcione en el navegador -- desde SSR con recarga de módulos en caliente, hasta enlace de datos bidireccional, envío de formularios a través de acciones del servidor, y el bug de persistencia que casi lo rompe todo.

Thales & Claude | March 30, 2026 9 min flin
EN/ FR/ ES
flinrust

Construir un motor de base de datos es una cosa. Hacer que funcione cuando los usuarios interactúan con él a través de un navegador web es otra cosa completamente diferente. El navegador es un entorno hostil para operaciones de base de datos. No hay sistema de archivos (directamente). No hay almacenamiento persistente que sobreviva a una recarga de página (sin esfuerzo). Hay una frontera fundamental entre el JavaScript del lado del cliente y la lógica del lado del servidor que debe cruzarse con cuidado.

Las Sesiones 201 y 203 fueron donde FlinDB se encontró con el navegador. La Sesión 201 corrigió el modelo de interactividad -- haciendo que los botones funcionen, los inputs se enlacen y los formularios se envíen. La Sesión 203 corrigió el modelo de persistencia -- asegurando que los datos realmente sobrevivan a una recarga de página. Juntas, revelaron la brecha entre "la base de datos pasa todas las pruebas" y "la base de datos funciona cuando una persona real la usa en un navegador real".

La arquitectura: SSR con acciones del servidor

El modelo de renderizado web de FLIN es renderizado del lado del servidor (SSR) con recarga de módulos en caliente (HMR). No es un framework de aplicación de página única. No es WASM ejecutándose en el navegador. El runtime de FLIN se ejecuta en el servidor, renderiza HTML y lo envía al navegador.

Browser                          Server
   |                               |
   |  1. Request page              |
   |  --------------------------->  |
   |                               |  2. FLIN runtime renders HTML
   |                               |  3. FlinDB queries execute server-side
   |                               |
   |  4. HTML + JS runtime         |
   |  <---------------------------  |
   |                               |
   |  5. User interacts            |
   |  (clicks, types, submits)     |
   |                               |

El navegador recibe HTML completamente renderizado más un pequeño runtime JavaScript (proxy $flin) que maneja las actualizaciones de estado del lado del cliente. Cuando el usuario hace clic en un botón o escribe en un input, el runtime JS actualiza el estado local y re-renderiza los elementos afectados. Cuando el usuario envía un formulario que requiere operaciones del lado del servidor (como guardar en FlinDB), el runtime JS envía una solicitud POST al servidor.

Sesión 201: haciendo el navegador interactivo

El problema

Las pruebas internas (2.926 pasando) mostraban un sistema perfectamente funcional. Pero al abrir las demos embebidas en un navegador:

  1. Los botones del contador no incrementaban ni decrementaban
  2. El input de tareas no se actualizaba al escribir
  3. El envío de formularios no creaba tareas
  4. El título principal estaba cortado en la parte superior de la página

Cuatro bugs, cada uno con una causa raíz diferente.

Corrección 1: enlace de datos bidireccional

El atributo bind={} debía crear enlace de datos bidireccional entre un elemento input y una variable FLIN. Pero el renderizador no lo manejaba:

flin<input bind={newTodo} />

Se renderizaba como:

html<input bind="" />

Sin valor. Sin listener de eventos. El input era decorativo. La corrección fue generar atributos HTML apropiados:

rustif attr.name == "bind" {
    if let AttrValue::Expr(expr) = &attr.value {
        let js_expr = expr_to_js(expr);
        let value = eval_expr_to_string_with_scope(expr, vm, scope);
        return format!(
            r#"value="{}" oninput="{} = this.value; _flinUpdate()" data-flin-bind="{}""#,
            escape_html_attr(&value),
            js_expr,
            escape_html_attr(&js_expr)
        );
    }
}

Ahora <input bind={newTodo}> se renderiza como:

html<input value="" oninput="newTodo = this.value; _flinUpdate()" data-flin-bind="newTodo" />

Tres atributos: value para el estado inicial, oninput para el enlace bidireccional, y data-flin-bind para identificación.

Corrección 2: envío de formularios vía acciones del servidor

El manejador de envío de formularios tenía un problema fundamental. submit={addTodo} intentaba llamar a addTodo() como una función JavaScript. Pero addTodo solo existía como bytecode FLIN en el servidor -- nunca fue transpilado a JavaScript.

La solución: acciones del servidor. En lugar de llamar a una función JS, el formulario envía una solicitud POST a un endpoint /_action:

rustif attr.name == "submit" {
    if let Expr::Identifier { name: fn_name, .. } = expr {
        return format!(
            r#"onsubmit="event.preventDefault(); _flinSubmit('{}'); return false;""#,
            escape_html_attr(fn_name)
        );
    }
}

La función _flinSubmit() recolecta el estado actual del proxy $flin y lo envía por POST al servidor.

El flujo completo de datos

Browser                          Server
   |                               |
   |  1. User types "Buy milk"     |
   |     oninput updates $flin     |
   |                               |
   |  2. User presses Enter        |
   |     _flinSubmit('addTodo')    |
   |                               |
   |  3. POST /_action             |
   |     {action: 'addTodo',       |
   |      state: {newTodo: '...'}} |
   |  --------------------------->  |
   |                               |
   |                               |  4. Compile: source + "addTodo()"
   |                               |  5. Inject state into VM
   |                               |  6. Execute (entity saved to FlinDB)
   |                               |
   |  7. 302 Redirect              |
   |  <---------------------------  |
   |                               |
   |  8. Page reloads              |
   |     New todo visible          |
   |                               |

Sesión 203: el bug de persistencia

La Sesión 201 corrigió la interactividad. El contador funcionaba. Los inputs se enlazaban. Los formularios se enviaban. Pero cuando la página se refrescaba, las tareas desaparecían. El archivo WAL se creaba pero contenía 0 bytes.

Este era un bug crítico: FlinDB parecía funcionar (el manejador de acciones retornaba 302, la página recargaba, la tarea era visible durante el ciclo de redirección) pero nada se persistía.

Causa raíz 1: el bytecode sobrescribe el estado inyectado

El servidor de desarrollo inyecta variables de estado antes de la ejecución del bytecode:

rustvm.set_global("newTodo", Value::Text("Buy milk".into()));

Pero el código fuente FLIN declara newTodo = "" al nivel superior. Cuando el bytecode se ejecuta, sobrescribe el valor inyectado con una cadena vacía. Para cuando addTodo() se ejecuta, newTodo está vacío.

La corrección: globales protegidas.

rustpub fn set_global_protected(&mut self, name: String, value: Value) {
    self.globals.insert(name.clone(), value);
    self.protected_globals.insert(name);
}

// In OpCode::StoreGlobal:
if !self.protected_globals.contains(&name) {
    self.globals.insert(name, value);
}

Las globales protegidas no pueden ser sobrescritas por el bytecode.

Causa raíz 2: Value::Text no manejado por Trim

La inyección de estado crea Value::Text("Buy milk"). Pero OpCode::Trim solo manejaba Value::Object(id) (objetos cadena asignados en el heap). Para Value::Text, retornaba una cadena vacía.

rust// Before fix:
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    _ => String::new(),  // Value::Text falls here!
};

// After fix:
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    Value::Text(t) => t.trim().to_string(),  // Handle Value::Text
    _ => String::new(),
};

Dos líneas. Pero sin ellas, cada operación de cadena sobre estado inyectado produciría resultados vacíos.

Verificación

Antes de la corrección:

bash$ cat embedded/todo-app/.flindb/wal.log
# (empty - 0 bytes)

Después de la corrección:

bash$ cat embedded/todo-app/.flindb/wal.log
{"type":"Save","timestamp":1768567212273,"entity_type":"Todo","entity_id":1,
 "version":1,"data":{"title":{"Object":453}},...}

177 bytes. Datos persistiendo. El WAL ahora contenía la entidad Todo guardada, y reiniciar el servidor la recuperaba a través de la reproducción del WAL.

La lección

Las Sesiones 201 y 203 nos enseñaron algo fundamental sobre la ingeniería de bases de datos: una base de datos que pasa todas sus pruebas aún puede fallar en producción.

FlinDB tenía 2.248 pruebas pasando. Cada operación CRUD funcionaba. Cada restricción se aplicaba. Cada índice se utilizaba. Pero ninguna de esas pruebas simulaba el viaje completo de ida y vuelta: navegador -> envío de formulario -> acción del servidor -> inyección de estado -> ejecución de VM -> guardado en FlinDB -> escritura WAL -> destrucción de VM -> nueva VM -> reproducción WAL -> renderizado de página -> navegador.

Los bugs no estaban en la capa de base de datos de FlinDB. Estaban en los puntos de integración: cómo se comunica el navegador con el servidor, cómo el servidor inyecta estado en la VM, cómo la VM maneja diferentes tipos de valores de cadena. La base de datos era correcta. El pegamento alrededor no lo era.

Lo que lo hizo funcionar

Tres decisiones arquitectónicas hicieron posible la persistencia en el navegador:

Primero, el modelo de persistencia basado en WAL. Cada mutación se escribe en un archivo de log. Cuando la VM del manejador de acciones guarda una tarea, la entrada WAL se vuelca inmediatamente a disco. Cuando la VM de renderizado de página se inicia, reproduce el WAL y recupera la tarea guardada. El WAL es el puente entre los tiempos de vida de las VMs.

Segundo, renderizado del lado del servidor. Porque FLIN renderiza en el servidor, las operaciones de base de datos se ejecutan en el servidor donde FlinDB tiene acceso directo al sistema de archivos. No hay necesidad de portar FlinDB a WebAssembly ni de usar IndexedDB. El navegador envía estado al servidor, el servidor ejecuta la operación, el servidor devuelve el resultado. Simple.

Tercero, el proxy $flin. El runtime JavaScript del lado del cliente mantiene un objeto proxy que refleja el estado del lado del servidor. El enlace bidireccional mantiene el proxy sincronizado con los valores de los inputs. Cuando se envía un formulario, el estado del proxy se serializa y se envía al servidor.

El resultado es una base de datos que se siente instantánea desde la perspectiva del usuario -- escribe una tarea, presiona Enter, la ve aparecer -- mientras se ejecuta completamente en el servidor con garantías ACID completas, persistencia WAL y recuperación ante fallos. El navegador es una capa de presentación delgada. La base de datos es real.


Esta es la Parte 15 de la serie "Cómo construimos FlinDB", documentando cómo construimos un motor de base de datos embebido completo para el lenguaje de programación FLIN.

Navegación de la serie: - [068] FlinDB Hardening for Production - [069] FlinDB vs SQLite: Why We Built Our Own - [070] Persistence in the Browser (estás aquí)

Esto concluye el arco de FlinDB. Desde almacenamiento embebido sin configuración hasta persistencia en el navegador, desde diseño centrado en entidades hasta event sourcing EAVT, desde índices hash hasta búsqueda semántica -- FlinDB es un motor de base de datos completo construido para un lenguaje que cree que los datos simplemente deberían funcionar.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles