Porque ainda não usamos uma base de dados
Avaliámos o Turso/libSQL para a nossa aplicação de escrita manual no iPad, medimos tudo e escolhemos ficheiros simples de snapshot. A carga de trabalho não precisa de uma base de dados — e cada passo só deve pagar pelos problemas que já existem.
O Lulucat Notes é uma aplicação de escrita manual para iPad. Até à semana passada, tinha um canvas e não conhecia o conceito de uma segunda nota. Estávamos prestes a acrescentar uma biblioteca de notas — vários documentos, cada um com várias páginas — e a primeira questão de arquitetura era o armazenamento.
Uma base de dados parecia a resposta óbvia. As aplicações de notas guardam dados estruturados. Os dados estruturados vão para bases de dados. Avaliámos o Turso e o seu Swift SDK, fizemo-lo correr no simulador do iOS, medimos dados reais de traços e depois decidimos não o utilizar.
Escolhemos ficheiros simples. Eis o que encontrámos e por que tomámos essa decisão.

Fotografia de Gabriel Cox no Unsplash. Licença do Unsplash.
O que a aplicação faz realmente com os dados
Uma aplicação de escrita manual tem um padrão de acesso aos dados estreito e previsível. Ler significa abrir uma página e carregar de uma vez para a memória todos os seus elementos — todos os traços, todas as imagens. O canvas contém tudo; nunca executa uma consulta parcial. Escrever significa terminar um traço de caneta e acrescentar um elemento à página. Em casos raros, o utilizador apaga parte de um traço, move uma seleção ou elimina alguma coisa, mas continuam a ser operações de uma única página e de um único elemento.
Não há acesso concorrente. Uma pessoa escreve de cada vez numa página de um documento. Também não há pesquisa entre documentos — a biblioteca de notas só precisa do título, da marca temporal, do número de páginas e da miniatura de capa de cada documento; nada disso exige ler o conteúdo das páginas.
As bases de dados existem para consultas, índices e coordenação da concorrência. A nossa aplicação não usa nenhuma destas três coisas.
A avaliação do Turso
Avaliámos o libsql-swift, o Swift SDK oficial para o motor libSQL do Turso.
O SDK funciona. Os 9 casos de teste passam todos. Integrámo-lo numa cópia da aplicação, compilámo-la para o simulador do iOS, lançámo-la e criámos uma base de dados local na sandbox da aplicação. Escrevemos 100 traços com 3400 pontos de amostragem cada — 4,080,000 bytes de dados BLOB — numa única transação. No nosso Mac de desenvolvimento, demorou cerca de 0.019s.
Depois de executar PRAGMA wal_checkpoint(TRUNCATE), o ficheiro WAL encolheu até zero e pudemos copiar apenas o ficheiro .db principal para outro local, abri-lo e ler novamente todos os dados. O motor em si é sólido.
O SDK tem custos. O CLibsql.xcframework pesa 161 MB. Depois de o ligar, a nossa compilação Debug para o simulador passou de aproximadamente 1.9 MB para aproximadamente 8.2 MB. A API é síncrona e bloqueante, sem wrappers de Swift Concurrency. Não existe um método close() explícito. Transaction.commit() não lança exceções — a API C subjacente devolve void. O README do repositório classifica o SDK como “technical preview”, e o commit mais recente tinha sido feito cerca de um ano antes da nossa avaliação, em julho de 2025.
O ecossistema Turso tem uma lacuna. Para projetos novos, o Turso recomenda agora o seu novo motor “Turso Database” e o protocolo “Turso Sync”. O Turso Sync tem SDKs de cliente para TypeScript, Python, Go e Rust. Não tem um para Swift. O modo antigo Embedded Replica existe no libsql-swift, mas o seu inicializador Swift não expõe o parâmetro offline necessário para uma aplicação móvel totalmente local-first. Adotar hoje o libsql-swift dá-nos um fork local do SQLite, mas não as capacidades de sincronização que distinguem o Turso.
Quanto nos custaria uma base de dados agora
Mesmo que o SDK fosse maduro, continuaríamos a pagar custos que não compram nada para a nossa carga de trabalho:
Gestão dos ficheiros auxiliares do WAL. Uma base de dados em execução cria os ficheiros companheiros -wal e -shm. Copiar um documento exige fazer primeiro um checkpoint ou copiar atomicamente os três ficheiros. Exportar um pacote .lnote para o Files ou para o AirDrop passaria a exigir um passo de pré-exportação que o utilizador não vê e que o programador não pode esquecer.
Uma camada de adaptação. Seria necessário serializar os traços para BLOBs e desserializá-los novamente. Os elementos da página têm uma ordem natural de array que o canvas renderiza diretamente; uma base de dados introduziria ordenação de linhas e colunas z-index. Escreveríamos uma camada de tradução entre duas representações dos mesmos dados e teríamos de a manter a cada alteração do schema.
Uma dependência de 161 MB. Para uma aplicação cuja compilação Debug tem menos de 2 MB, uma dependência com mais de 80× o tamanho da própria aplicação é um custo que merece atenção — sobretudo quando está marcada como “technical preview” e inativa há um ano.
Estes custos não são hipotéticos. Começam no momento em que a dependência é ligada. E compram capacidades — consultas, indexação e escritas concorrentes — que a aplicação não utiliza.
A solução que enviámos: pacotes de ficheiros snapshot
Um documento .lnote é um pacote de diretório:
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
O document.json é a fonte de verdade da estrutura do documento: o seu ID, título, marcas temporais e uma lista ordenada de páginas, cada uma com o tamanho do canvas, marcas temporais e contagem de elementos. Os ficheiros de conteúdo das páginas guardam o array de elementos na mesma codificação inteira quantificada que a aplicação já utiliza — coordenadas e raios com precisão de 0.1 pontos, pressão em milésimos e marcas temporais em milissegundos relativos.
A biblioteca de notas lê apenas o manifest.json e as miniaturas de capa. Nunca analisa o document.json nem o conteúdo de qualquer página. Abrir uma página carrega um ficheiro .content. É a única leitura de ficheiro que toca nos dados dos traços.
As páginas resolvem o write amplification
Uma aplicação de escrita manual já tem o conceito de página — é a unidade em que os utilizadores pensam, aquilo entre o que deslizam. Tornar a página a unidade de persistência significa que o guardado automático só reescreve as páginas que mudaram.
Uma página de escrita manual — digamos, 1,000 a 2,000 traços — ocupa aproximadamente 3–5 MB no nosso formato quantificado. Uma gravação de 21 traços com 3400 pontos de amostragem quantifica para cerca de 55 KB. Gravar um snapshot de uma página no armazenamento flash demora 10–20 ms em hardware moderno. Com um debounce de 0.5s, os guardados são invisíveis para o utilizador.
O custo de guardar cresce com a quantidade de escrita na página atual, não com o número total de páginas do documento. Um caderno de 200 páginas guarda-se exatamente tão depressa como um caderno de 2 páginas, porque só a página alterada é reescrita.
Cada escrita usa operações atómicas de ficheiros — escrever num ficheiro temporário e depois mudar-lhe o nome —, pelo que uma falha a meio do guardado não pode produzir uma página truncada. Ao passar para segundo plano, a aplicação descarrega imediatamente todas as páginas alteradas, mantendo o comportamento que já tinha com um único canvas.
Consistência sem transações
Os pacotes de ficheiros não têm transações, mas têm regras claras de propriedade que cumprem a mesma função:
Recursos antes das referências. Quando o utilizador insere uma imagem, o ficheiro do asset é escrito imediatamente em assets/. O snapshot da página, que referencia o asset pelo ID, é escrito mais tarde pelo guardado automático com debounce. Em nenhum momento uma página referencia um asset que não existe no disco.
A fonte de verdade ganha. document.json e o diretório pages/ são a fonte de verdade. manifest.json é uma cache. Se houver divergências, o guardado seguinte reconcilia a cache para corresponder à fonte. As miniaturas são derivadas e podem ser regeneradas a qualquer momento.
Órfãos em vez de referências pendentes. O pior resultado de uma falha é um asset órfão — um ficheiro em assets/ que nenhuma página referencia. Os órfãos são limpos quando o documento é fechado. O inverso — uma página a referenciar um ficheiro em falta — não pode acontecer, porque os assets são escritos antes do snapshot da página que os referencia.
Estas regras são mais fáceis de compreender do que o WAL checkpointing e o isolamento de transações, e correspondem exatamente ao padrão de acesso single-process, single-page da aplicação.
O caminho de atualização está escrito
Escolher ficheiros simples agora não significa escolhê-los para sempre. A estrutura do pacote foi concebida para que atualizar o motor de armazenamento mude o que está dentro do pacote sem mudar o pacote em si.
Nível 1: snapshot + append journal. Se o write amplification se tornar algum dia percetível — por exemplo, se a escrita contínua numa página com milhares de traços causar um atraso de gravação notório — cada ficheiro de página será dividido num snapshot e num journal apenas de acréscimo. Os novos elementos serão acrescentados como frames [length][CRC][type][payload]. Ao reproduzir o journal, qualquer frame cujo CRC não corresponda será descartado, proporcionando segurança contra falhas. Quando o journal ultrapassar um limite ou a página for fechada, será novamente unido ao snapshot. Isto corresponde a cerca de ~200 LOC, sem dependências externas.
Como os snapshots ao nível da página já eliminam o write amplification entre páginas, este nível pode não ser necessário durante muito tempo. Reescrever uma página de 5 MB a cada 0.5s fica confortavelmente dentro dos limites de escrita da memória flash.
Nível 2: base de dados SQLite. Se a aplicação precisar algum dia de pesquisa full-text entre notas, sincronização por elemento ou indexação entre documentos, o SQLite será a ferramenta certa. O motor provável nessa altura será o GRDB, um wrapper Swift maduro, compilado a partir do código-fonte, com overhead de tamanho binário quase nulo. Só voltaríamos a considerar o libsql-swift se o ecossistema Turso — especificamente o Turso Sync para Swift — se tornasse uma necessidade real do produto.
A migração é mecânica: o array de elementos de cada página será mapeado para uma tabela strokes / images, com um BLOB imutável por traço (16 bytes por ponto de amostragem em binário little-endian). O benchmark de 0.019s para 100 traços confirma que a abordagem é viável. Antes de publicar, a migração verificará se as contagens de elementos e assets coincidem entre o pacote antigo e a nova base de dados, e se a recuperação de falhas, os limites do WAL e o flush em segundo plano passam todos.
Quando pagar
Esta decisão não é um juízo sobre bases de dados. O SQLite trata de escritores concorrentes, consultas complexas e recuperação de falhas sobre estado partilhado — nada disso é atualmente necessário na nossa aplicação. Pagar por capacidades antes de a aplicação ter os problemas que elas resolvem é uma perda líquida.
Os custos de uma base de dados — a dependência, a gestão do WAL, a camada de adaptação e o tamanho do binário — começam no momento em que a biblioteca é ligada. Os benefícios começam quando a aplicação tem consultas para executar, índices para manter ou escritores concorrentes para coordenar. Nesta fase, não tem nenhum dos três.
Cada passo da evolução do nosso armazenamento pagará apenas pelos problemas que já tenham aparecido. Os snapshots ao nível da página pagam pelo problema que temos hoje: guardar documentos com várias páginas sem reescrever o ficheiro inteiro. Se o write amplification se tornar mensurável, um append journal pagará por esse problema. Se a pesquisa ou a sincronização se tornar uma necessidade do produto, uma base de dados pagará por esse problema.
A estrutura do pacote, o manifest e o schema do documento não estão presos a nenhum motor de armazenamento. Os custos de mudança são baixos porque as fronteiras estão nos sítios certos. Quando chegar o dia em que precisarmos realmente de uma base de dados, iremos adotá-la para um problema específico e já medido — não para um problema hipotético.