Por qué todavía no usamos una base de datos
Evaluamos Turso/libSQL para nuestra app 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 solo debería pagar por problemas que ya existen.
Lulucat Notes es una app de escritura a mano para iPad. Hasta la semana pasada solo tenía un lienzo y no contemplaba una segunda nota. Estábamos a punto de añadir una biblioteca de notas —varios documentos, cada uno con varias páginas— y la primera pregunta arquitectónica era dónde guardar los datos.
Una base de datos parecía la respuesta obvia. Las apps de notas almacenan datos estructurados. Los datos estructurados van en bases de datos. Evaluamos Turso y su SDK de Swift, lo pusimos en marcha en el simulador de iOS, medimos datos de trazos reales y después decidimos no usarlo.
Elegimos archivos planos. Esto es lo que descubrimos y por qué tomamos esa decisión.

Foto de Gabriel Cox en Unsplash. Licencia de Unsplash.
Lo que la app hace realmente con los datos
Una app de escritura a mano tiene un patrón de acceso a los datos limitado y predecible. Leer significa abrir una página y cargar en memoria todos sus elementos a la vez —todos los trazos y todas las imágenes—. El lienzo lo contiene todo; nunca ejecuta una consulta parcial. Escribir significa terminar un trazo y añadir un elemento a la página. En casos excepcionales, el usuario borra parte de un trazo, mueve una selección o elimina algo, pero siguen siendo operaciones sobre un solo elemento de una sola página.
No hay acceso concurrente. Una persona escribe en una página de un documento cada vez. No hay búsquedas entre documentos: la biblioteca de notas solo necesita un título, una marca de tiempo, un recuento de páginas y una miniatura de portada por documento, y nada de eso requiere leer el contenido de las páginas.
Las consultas, los índices y la coordinación de la concurrencia son para lo que se construyen las bases de datos. Nuestra app no usa ninguna de las tres cosas.
La evaluación de Turso
Evaluamos libsql-swift, el SDK oficial de Swift para el motor libSQL de Turso.
El SDK funciona. Sus nueve casos de prueba pasan. Lo integramos en una copia de la app, compilamos para el simulador de iOS, la ejecutamos y creamos una base de datos local en el sandbox de la app. Escribimos 100 trazos de 3,400 puntos de muestreo cada uno —4,080,000 bytes de datos BLOB— en una sola transacción. Tardó unos 0.019 segundos en nuestro Mac de desarrollo.
Después de ejecutar PRAGMA wal_checkpoint(TRUNCATE), el archivo WAL se redujo a cero y pudimos copiar solo el archivo .db principal a otra ubicación, abrirlo y volver a leer todos los datos. El motor en sí es sólido.
El SDK tiene costes. CLibsql.xcframework pesa 161 MB. Después de enlazarlo, nuestra compilación Debug para el simulador pasó de aproximadamente 1.9 MB a aproximadamente 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 califica el SDK de «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 una pieza. Turso recomienda ahora su nuevo motor «Turso Database» y el protocolo «Turso Sync» para los proyectos nuevos. Turso Sync tiene SDK de cliente para TypeScript, Python, Go y Rust. No tiene uno para Swift. El modo Embedded Replica antiguo existe en libsql-swift, pero su inicializador de Swift no expone el parámetro offline necesario para una app móvil totalmente local-first. Adoptar libsql-swift hoy nos da una bifurcación local de SQLite, pero no las capacidades de sincronización que distinguen a Turso.
Lo que nos costaría una base de datos ahora mismo
Aunque el SDK fuera maduro, seguiríamos pagando costes que no aportan nada a nuestra carga de trabajo:
Gestión de archivos auxiliares WAL. Una base de datos en ejecución crea 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 requeriría ahora un paso previo de exportación que el usuario no puede ver y el desarrollador no puede olvidar.
Una capa adaptadora. Habría que serializar los trazos en BLOB y deserializarlos después. Los elementos de una página tienen un orden natural de array que el lienzo representa directamente; una base de datos introduciría columnas de orden de filas y z-index. Estaríamos escribiendo una capa de traducción entre dos representaciones de los mismos datos y manteniéndola con cada cambio de esquema.
Una dependencia de 161 MB. Para una app cuya compilación Debug ocupa menos de 2 MB, una dependencia de más de 80× el tamaño de la app es un coste que merece atención, sobre todo si está etiquetada como «technical preview» y lleva un año inactiva.
Estos costes no son hipotéticos. Empiezan en el momento en que se enlaza la dependencia. Y nos compran capacidades —consultas, indexación y escrituras concurrentes— que nuestra app no utiliza.
La solución que distribuimos: 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 recuento 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 ya usa la app: coordenadas y radios con una 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 único 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 app 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 de la página la unidad de persistencia significa que el autoguardado solo reescribe las páginas que han cambiado.
Una sola página manuscrita —por ejemplo, 1,000 a 2,000 trazos— ocupa aproximadamente 3–5 MB en nuestro formato cuantizado. Una grabación de 21 trazos con 3,400 puntos de muestreo se cuantiza hasta unos 55 KB. Escribir una instantánea de página en el almacenamiento flash tarda 10–20 ms en el hardware moderno. Con un debounce de 0.5 segundos, los guardados son invisibles para el usuario.
El coste de guardado escala con la cantidad de escritura de la página actual, no con el número total de páginas del documento. Un cuaderno de 200 páginas se guarda exactamente igual de rápido que uno de 2 páginas, porque solo se reescribe la página modificada.
Cada escritura utiliza operaciones de archivo atómicas —escribir en un archivo temporal y después cambiarle el nombre—, así que un cierre inesperado durante el guardado no puede producir una página truncada. Al pasar a segundo plano, la app vuelca inmediatamente todas las páginas modificadas, igual que ya hacía cuando solo tenía un lienzo.
Consistencia sin transacciones
Los paquetes de archivos no tienen transacciones, pero sí reglas claras de propiedad que cumplen la misma función:
Recursos antes que referencias. Cuando el usuario inserta una imagen, el archivo del recurso se escribe inmediatamente en assets/. La instantánea de la página, que referencia el recurso por su ID, se escribe después mediante el autoguardado con debounce. En ningún momento una página referencia un recurso que no exista en el disco.
Gana la fuente de verdad. document.json y el directorio pages/ son la fuente de verdad. manifest.json es una caché. Si no coinciden, el siguiente guardado reconcilia la caché para que coincida con la fuente. Las miniaturas son derivadas y se pueden regenerar 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 no referencia ninguna página. Los huérfanos se limpian al cerrar el documento. Lo contrario —que una página haga referencia a un archivo inexistente— no puede ocurrir, porque los recursos se escriben antes que la instantánea de página que los referencia.
Estas reglas son más sencillas de razonar que el checkpointing de WAL y el aislamiento de transacciones, y encajan exactamente con el patrón de acceso de un solo proceso y una sola página de la app.
La ruta de actualización está escrita
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 en sí.
Nivel 1: instantánea + diario de anexado. Si la amplificación de escritura llegara a ser perceptible —por ejemplo, si escribir de forma continua en una página con miles de trazos provocara 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 añadirían como tramas [length][CRC][type][payload]. Al reproducir el diario se descarta cualquier trama cuyo CRC no coincida, lo que proporciona seguridad frente a 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 presupuestos de escritura flash.
Nivel 2: base de datos SQLite. Si la app necesitara alguna vez 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 entonces sería GRDB, un wrapper de Swift maduro compilado desde el código fuente y con un coste binario casi nulo. Solo volveríamos a considerar libsql-swift si el ecosistema de Turso —en concreto, 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 distribuirla, la migración verificaría que los recuentos de elementos y recursos coinciden entre el paquete antiguo y la base de datos nueva, y que pasan las pruebas de recuperación tras un cierre inesperado, límites del WAL y vaciado en segundo plano.
Cuándo pagar
Esta decisión no es un juicio sobre las bases de datos. SQLite gestiona escritores concurrentes, consultas complejas y recuperación tras fallos sobre un estado compartido; nada de eso necesita nuestra app ahora mismo. Pagar por esas capacidades antes de que la app tenga los problemas que resuelven es una pérdida neta.
Los costes de una base de datos —la dependencia, la gestión del WAL, la capa adaptadora y el tamaño del binario— empiezan en el momento en que se enlaza la biblioteca. Los beneficios empiezan cuando la app tiene consultas que ejecutar, índices que mantener o escritores concurrentes que coordinar. En esta fase 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 reescribir el archivo entero. 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 convierten en 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 ligados a ningún motor de almacenamiento. El coste 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.