Lulucat

Por qué aún no usamos una base de datos

Gaoge ZhangGaoge Zhang

Evaluamos Turso/libSQL para nuestra aplicación de escritura a mano en iPad, medimos todo y elegimos archivos planos. La carga de trabajo no necesita una base de datos, y cada paso debería pagar únicamente por problemas que ya existen.

Lulucat Notes es una aplicación de escritura a mano para iPad. Hasta la semana pasada tenía un solo lienzo y ni siquiera el concepto de una segunda nota. Estábamos por agregar una biblioteca de notas —varios documentos, cada uno con varias páginas— y la primera pregunta arquitectónica era el almacenamiento.

Una base de datos parecía la respuesta obvia. Las aplicaciones de notas guardan datos estructurados. Los datos estructurados van en bases de datos. Evaluamos Turso y su SDK de Swift, lo hicimos funcionar en el simulador de iOS, medimos datos reales de trazos y luego decidimos no usarlo.

Elegimos archivos planos. Esto fue lo que encontramos y por qué tomamos esa decisión.

Un cuaderno abierto con notas escritas a mano y una pluma sobre un escritorio de madera.

Foto de Gabriel Cox en Unsplash. Licencia de Unsplash.

Lo que la aplicación realmente hace con los datos

Una aplicación de escritura a mano tiene un patrón de acceso a datos limitado y predecible. Leer significa abrir una página y cargar de una vez en memoria todos sus elementos —todos los trazos y todas las imágenes—. El lienzo contiene todo; nunca ejecuta una consulta parcial. Escribir significa terminar un trazo y agregar un elemento a la página. En casos poco frecuentes, el usuario borra parte de un trazo, mueve una selección o elimina algo, pero siguen siendo operaciones de una sola página y un solo elemento.

No hay acceso concurrente. Una sola persona escribe en una página de un documento a la vez. No hay búsqueda entre documentos: la biblioteca de notas solo necesita el título, una marca de tiempo, la cantidad de páginas y una miniatura de portada de cada documento; nada de eso requiere leer el contenido de las páginas.

Las bases de datos se construyen para las consultas, los índices y la coordinación de concurrencia. Nuestra aplicación no usa ninguna de esas tres cosas.

La evaluación de Turso

Evaluamos libsql-swift, el SDK oficial de Swift para el motor libSQL de Turso.

El SDK funciona. Los nueve casos de prueba pasan. Lo integramos en una copia de la aplicación, compilamos para el simulador de iOS, la iniciamos y creamos una base de datos local en el sandbox de la aplicación. Escribimos 100 trazos de 3,400 puntos de muestreo cada uno —4,080,000 bytes de datos BLOB— en una sola transacción. En nuestra Mac de desarrollo tardó aproximadamente 0.019 segundos.

Después de ejecutar PRAGMA wal_checkpoint(TRUNCATE), el archivo WAL quedó en cero y pudimos copiar solo el archivo .db principal a otra ubicación, abrirlo y leer de nuevo todos los datos. El motor en sí es sólido.

El SDK tiene costos. CLibsql.xcframework pesa 161 MB. Después de enlazarlo, nuestra compilación Debug del simulador pasó de alrededor de 1.9 MB a alrededor de 8.2 MB. La API es síncrona y bloqueante, sin wrappers para Swift Concurrency. No hay un método close() explícito. Transaction.commit() no lanza errores: la API C subyacente devuelve void. El README del repositorio etiqueta el SDK como «technical preview», y el commit más reciente era de aproximadamente un año antes de nuestra evaluación, en julio de 2025.

Al ecosistema de Turso le falta algo. Turso ahora recomienda su nuevo motor «Turso Database» y el protocolo «Turso Sync» para proyectos nuevos. Turso Sync tiene SDK de cliente para TypeScript, Python, Go y Rust. No tiene uno para Swift. El antiguo modo Embedded Replica existe en libsql-swift, pero su inicializador de Swift no expone el parámetro offline necesario para una aplicación móvil completamente local-first. Adoptar libsql-swift hoy nos da una variante local de SQLite, pero no las capacidades de sincronización que hacen diferente a Turso.

Lo que una base de datos nos costaría ahora

Aunque el SDK fuera maduro, pagaríamos costos que no aportan nada a nuestra carga de trabajo:

Administrar los archivos auxiliares del WAL. Una base de datos en ejecución crea los archivos acompañantes -wal y -shm. Copiar un documento exige hacer checkpoint primero o copiar los tres archivos de forma atómica. Exportar un paquete .lnote a Archivos o AirDrop ahora requeriría un paso previo de exportación que el usuario no ve y que el desarrollador no puede olvidar.

Una capa adaptadora. Habría que serializar los trazos en BLOB y volver a deserializarlos. Los elementos de una página tienen un orden natural de array que el lienzo renderiza directamente; una base de datos introduciría columnas para el orden de las filas y el z-index. Escribiríamos una capa de traducción entre dos representaciones de los mismos datos y tendríamos que mantenerla con cada cambio de esquema.

Una dependencia de 161 MB. Para una aplicación cuya compilación Debug ocupa menos de 2 MB, una dependencia que supera 80× el tamaño de la propia aplicación es un costo que hay que tomar en cuenta, especialmente si está etiquetada como «technical preview» y lleva un año sin actividad.

Estos costos no son hipotéticos. Comienzan en cuanto se enlaza la dependencia. Y nos compran capacidades —consultas, indexación y escrituras concurrentes— que la aplicación no utiliza.

La solución que publicamos: paquetes de archivos de instantáneas

Un documento .lnote es un paquete de directorio:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json es la fuente de verdad de la estructura del documento: su ID, título, marcas de tiempo y una lista ordenada de páginas con el tamaño del lienzo, las marcas de tiempo y el conteo de elementos de cada página. Los archivos de contenido de las páginas guardan el array de elementos con la misma codificación entera cuantizada que la aplicación ya usa: coordenadas y radios con precisión de 0.1 puntos, presión en milésimas y marcas de tiempo en milisegundos relativos.

La biblioteca de notas solo lee manifest.json y las miniaturas de portada. Nunca analiza document.json ni el contenido de ninguna página. Abrir una página carga un solo archivo .content. Esa es la única lectura de archivo que toca los datos de los trazos.

Las páginas resuelven la amplificación de escritura

Una aplicación de escritura a mano ya tiene el concepto de página: es la unidad que los usuarios tienen en mente y la que recorren al deslizarse. Hacer que la página sea la unidad de persistencia significa que el guardado automático solo vuelve a escribir las páginas que cambiaron.

Una sola página de escritura a mano —por ejemplo, 1,000 a 2,000 trazos— ocupa alrededor de 3–5 MB en nuestro formato cuantizado. Una grabación de 21 trazos con 3,400 puntos de muestreo se cuantiza a unos 55 KB. Escribir una instantánea de página en almacenamiento flash toma 10–20 ms en hardware moderno. Con un debounce de 0.5 segundos, los guardados son invisibles para el usuario.

El costo del guardado escala con la cantidad de escritura de la página actual, no con la cantidad total de páginas del documento. Un cuaderno de 200 páginas se guarda exactamente tan rápido como uno de 2 páginas, porque solo se vuelve a escribir la página modificada.

Cada escritura usa operaciones de archivo atómicas —escribir en un archivo temporal y luego cambiarle el nombre—, así que un cierre inesperado a mitad del guardado no puede producir una página truncada. Al pasar a segundo plano, la aplicación vuelca de inmediato todas las páginas modificadas, igual que ya lo hacía cuando tenía un solo lienzo.

Consistencia sin transacciones

Los paquetes de archivos no tienen transacciones, pero sí reglas claras de propiedad que cumplen el mismo propósito:

Recursos antes que referencias. Cuando el usuario inserta una imagen, el archivo del recurso se escribe de inmediato en assets/. La instantánea de la página, que hace referencia al recurso por su ID, se escribe después mediante el guardado automático con debounce. En ningún momento una página hace referencia a un recurso que no exista en el disco.

La fuente de verdad gana. document.json y el directorio pages/ son la fuente de verdad. manifest.json es un caché. Si no coinciden, el siguiente guardado reconcilia el caché para que coincida con la fuente. Las miniaturas son datos derivados y pueden regenerarse en cualquier momento.

Huérfanos antes que referencias colgantes. El peor resultado de un cierre inesperado es un recurso huérfano: un archivo en assets/ al que ninguna página hace referencia. Los huérfanos se limpian cuando se cierra el documento. Lo contrario —una página que hace referencia a un archivo faltante— no puede suceder, porque los recursos se escriben antes que la instantánea de la página que los referencia.

Estas reglas son más fáciles de razonar que el checkpointing del WAL y el aislamiento de transacciones, y coinciden exactamente con el patrón de acceso de una sola página y un solo proceso de la aplicación.

La ruta de actualización está documentada

Elegir archivos planos ahora no significa elegirlos para siempre. La estructura del paquete está diseñada para que actualizar el motor de almacenamiento cambie lo que hay dentro del paquete sin cambiar el paquete mismo.

Nivel 1: instantánea + diario de anexado. Si la amplificación de escritura alguna vez se volviera perceptible —por ejemplo, si escribir continuamente en una página con miles de trazos causara un retraso apreciable al guardar—, cada archivo de página se dividiría en una instantánea y un diario de solo anexado. Los elementos nuevos se agregarían como tramas [length][CRC][type][payload]. Al reproducir el diario se descarta cualquier trama cuyo CRC no coincida, lo que ofrece seguridad contra cierres inesperados. Cuando el diario supera un umbral o se cierra la página, vuelve a fusionarse con la instantánea. Son ~200 LOC y cero dependencias externas.

Como las instantáneas por página ya eliminan la amplificación de escritura entre páginas, quizá este nivel no sea necesario durante mucho tiempo. Reescribir una página de 5 MB cada 0.5 segundos está dentro de los límites de escritura flash.

Nivel 2: base de datos SQLite. Si la aplicación alguna vez necesitara búsqueda de texto completo entre notas, sincronización por elemento o indexación entre documentos, SQLite sería la herramienta adecuada. El motor más probable en ese momento sería GRDB, un wrapper de Swift maduro, compilado desde el código fuente y con un costo binario casi nulo. libsql-swift solo volvería a considerarse si el ecosistema de Turso —en especial Turso Sync para Swift— se convirtiera en una necesidad real del producto.

La migración es mecánica: el array de elementos de cada página se asigna a una tabla strokes / images con un BLOB inmutable por trazo (16 bytes por punto de muestreo en binario little-endian). La medición de 0.019 segundos para 100 trazos confirma que el enfoque es viable. Antes de publicar, la migración verificaría que los conteos de elementos y recursos coincidan entre el paquete antiguo y la nueva base de datos, y que pasen las pruebas de recuperación ante cierres inesperados, límites del WAL y descarga en segundo plano.

Cuándo pagar

Esta decisión no es un juicio sobre las bases de datos. SQLite maneja escritores concurrentes, consultas complejas y recuperación ante fallas sobre un estado compartido; nuestra aplicación no necesita nada de eso por ahora. Pagar por esas capacidades antes de que la aplicación tenga los problemas que resuelven es una pérdida neta.

Los costos de una base de datos —la dependencia, la administración del WAL, la capa adaptadora y el tamaño del binario— comienzan en cuanto se enlaza la biblioteca. Los beneficios aparecen cuando la aplicación tiene consultas que ejecutar, índices que mantener o escritores concurrentes que coordinar. En esta etapa no tiene ninguna de las tres cosas.

Cada paso de la evolución de nuestro almacenamiento solo pagará por problemas que ya hayan aparecido. Las instantáneas por página pagan por el problema que tenemos hoy: guardar documentos de varias páginas sin volver a escribir el archivo completo. Si la amplificación de escritura se vuelve medible, un diario de anexado pagará por ese problema. Si la búsqueda o la sincronización se vuelve una necesidad del producto, una base de datos pagará por ese problema.

La estructura del paquete, el manifiesto y el esquema del documento no están atados a ningún motor de almacenamiento. El costo de cambiar es bajo porque los límites están en el lugar correcto. Cuando llegue el día en que necesitemos una base de datos, la adoptaremos para un problema concreto y ya medido, no para uno hipotético.