Por que ainda não usamos um banco de dados
Avaliamos Turso/libSQL para nosso app de escrita à mão no iPad, medimos tudo e escolhemos arquivos simples de snapshot. A carga de trabalho não precisa de um banco de dados — e cada passo só deve pagar pelos problemas que já existem.
O Lulucat Notes é um app de escrita à mão para iPad. Até a semana passada, ele tinha um canvas e não tinha o conceito de uma segunda nota. Estávamos prestes a adicionar uma biblioteca de notas — vários documentos, cada um com várias páginas — e a primeira pergunta de arquitetura era armazenamento.
Um banco de dados parecia a resposta óbvia. Apps de notas armazenam dados estruturados. Dados estruturados vão para bancos de dados. Avaliamos o Turso e seu Swift SDK, fizemos tudo rodar no simulador do iOS, medimos dados reais de traços e então decidimos não usá-lo.
Escolhemos arquivos simples. É isso que encontramos e o motivo da decisão.

Foto de Gabriel Cox no Unsplash. Licença do Unsplash.
O que o app realmente faz com os dados
Um app de escrita à mão tem um padrão de acesso a dados estreito e previsível. Ler significa abrir uma página e carregar todos os seus elementos — todos os traços, todas as imagens — na memória de uma vez. O canvas contém tudo; ele nunca executa uma consulta parcial. Escrever significa terminar um traço de caneta e acrescentar um elemento à página. Em casos raros, o usuário apaga parte de um traço, move uma seleção ou exclui alguma coisa, mas essas ainda são operações de uma página e um elemento.
Não há acesso concorrente. Uma pessoa escreve em uma página de um documento por vez. Também não há busca entre documentos — a biblioteca de notas só precisa do título, timestamp, contagem de páginas e thumbnail de capa de cada documento; nada disso exige ler o conteúdo das páginas.
Consultas, índices e coordenação de concorrência são exatamente para isso que bancos de dados são feitos. Nosso app não usa nenhum dos três.
A avaliação do Turso
Avaliamos o libsql-swift, o Swift SDK oficial para o engine libSQL do Turso.
O SDK funciona. Todos os 9 casos de teste passam. Nós o integramos a uma cópia do app, compilamos para o simulador do iOS, abrimos o app e criamos um banco de dados local no sandbox do app. Escrevemos 100 traços de 3400 pontos de amostragem cada — 4,080,000 bytes de dados BLOB — em uma única transação. Isso levou cerca de 0.019s no nosso Mac de desenvolvimento.
Depois de executar PRAGMA wal_checkpoint(TRUNCATE), o arquivo WAL encolheu a zero e pudemos copiar apenas o arquivo .db principal para outro local, abri-lo e ler todos os dados de volta. O engine em si é sólido.
O SDK tem custos. O CLibsql.xcframework pesa 161 MB. Depois do link, o build Debug do nosso 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 há um método explícito close(). Transaction.commit() não lança erro — a API C subjacente retorna void. O README do repositório chama o SDK de “technical preview”, e o commit mais recente tinha acontecido cerca de um ano antes da nossa avaliação, em julho de 2025.
O ecossistema do Turso tem uma lacuna. Para projetos novos, o Turso agora recomenda seu novo engine “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 seu inicializador Swift não expõe o parâmetro offline necessário para um app mobile totalmente local-first. Adotar libsql-swift hoje nos dá um fork local do SQLite, mas não os recursos de sincronização que tornam o Turso diferente.
Quanto um banco de dados custaria para nós agora
Mesmo que o SDK fosse maduro, ainda pagaríamos custos que não compram nada para a nossa carga de trabalho:
Gerenciamento dos arquivos auxiliares do WAL. Um banco de dados em execução cria os arquivos companheiros -wal e -shm. Copiar um documento exige fazer um checkpoint antes ou copiar atomicamente os três arquivos. Exportar um pacote .lnote para o Files ou AirDrop passaria a exigir uma etapa antes da exportação que o usuário não consegue ver e o desenvolvedor não pode esquecer.
Uma camada de adaptação. Seria preciso serializar os traços em BLOBs e desserializá-los de volta. Os elementos da página têm uma ordem natural de array que o canvas renderiza diretamente; um banco de dados introduziria ordenação de linhas e colunas de z-index. Escreveríamos uma camada de tradução entre duas representações dos mesmos dados e a manteríamos a cada mudança de schema.
Uma dependência de 161 MB. Para um app cujo build Debug tem menos de 2 MB, uma dependência com mais de 80× o tamanho do próprio app é um custo que merece atenção — especialmente uma marcada como “technical preview” e sem atividade há um ano.
Esses custos não são hipotéticos. Eles começam no momento em que a dependência é linkada. E compram capacidades — consultas, indexação e escritas concorrentes — que nosso app não usa.
A solução que enviamos: pacotes de arquivos 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: seu ID, título, timestamps e uma lista ordenada de páginas, cada uma com tamanho do canvas, timestamps e contagem de elementos. Os arquivos de conteúdo das páginas armazenam o array de elementos na mesma codificação inteira quantizada que o app já usa — coordenadas e raios com precisão de 0.1 ponto, pressão em milésimos e timestamps em milissegundos relativos.
A biblioteca de notas lê apenas manifest.json e as thumbnails de capa. Ela nunca interpreta document.json nem o conteúdo de nenhuma página. Abrir uma página carrega um arquivo .content. Essa é a única leitura de arquivo que toca nos dados de traços.
Páginas resolvem o write amplification
Um app de escrita à mão já tem o conceito de página — é a unidade em que os usuários pensam, aquilo entre o que deslizam. Fazer da página a unidade de persistência significa que o salvamento automático só reescreve as páginas que mudaram.
Uma página de escrita à mão — digamos, 1,000 a 2,000 traços — ocupa aproximadamente 3–5 MB no nosso formato quantizado. Uma gravação de 21 traços com 3400 pontos de amostragem quantiza para cerca de 55 KB. Gravar um snapshot de uma página no armazenamento flash leva 10–20 ms em hardware moderno. Com um debounce de 0.5s, os salvamentos ficam invisíveis para o usuário.
O custo de salvar escala com a quantidade de escrita na página atual, não com o total de páginas do documento. Um caderno de 200 páginas salva exatamente tão rápido quanto um caderno de 2 páginas, porque só a página suja é reescrita.
Toda escrita usa operações atômicas de arquivo — escrever em um arquivo temporário e depois renomeá-lo —, então um crash no meio do salvamento não pode produzir uma página truncada. Ao entrar em background, o app descarrega imediatamente todas as páginas sujas, repetindo o comportamento que já tinha com um único canvas.
Consistência sem transações
Pacotes de arquivos 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 usuário insere uma imagem, o arquivo do asset é escrito imediatamente em assets/. O snapshot da página, que referencia o asset pelo ID, é escrito depois pelo salvamento automático com debounce. Em nenhum momento uma página referencia um asset que não existe no disco.
A fonte de verdade vence. document.json e o diretório pages/ são a fonte de verdade. manifest.json é um cache. Se houver divergência, o próximo salvamento reconcilia o cache para corresponder à fonte. Thumbnails são derivadas e podem ser regeneradas a qualquer momento.
Órfãos em vez de referências pendentes. O pior resultado de um crash é um asset órfão — um arquivo em assets/ que nenhuma página referencia. Os órfãos são limpos quando o documento é fechado. O inverso — uma página referenciando um arquivo ausente — não pode acontecer, porque os assets são escritos antes do snapshot da página que os referencia.
Essas regras são mais fáceis de entender do que checkpointing de WAL e isolamento de transações, e correspondem exatamente ao padrão de acesso single-process, single-page do app.
O caminho de upgrade está documentado
Escolher arquivos simples agora não significa escolhê-los para sempre. A estrutura do pacote foi desenhada para que fazer upgrade do engine 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 algum dia ficar perceptível — por exemplo, se escrever continuamente em uma página com milhares de traços causar um atraso de salvamento visível — cada arquivo de página se dividirá em um snapshot e um journal somente de acréscimos. Novos elementos serão acrescentados como frames [length][CRC][type][payload]. Ao reproduzir o journal, qualquer frame cujo CRC não corresponder será descartado, o que oferece segurança contra crashes. Quando o journal ultrapassar um limite ou a página for fechada, ele será mesclado de volta ao snapshot. Isso corresponde a cerca de ~200 LOC e zero dependências externas.
Como os snapshots por página já eliminam o write amplification entre páginas, esse nível talvez não seja necessário por muito tempo. Reescrever uma página de 5 MB a cada 0.5s fica tranquilamente dentro dos limites de escrita do flash.
Nível 2: banco de dados SQLite. Se o app algum dia precisar de busca full-text entre notas, sincronização por elemento ou indexação entre documentos, SQLite será a ferramenta certa. O engine provável nesse ponto será o GRDB, um wrapper Swift maduro, compilado a partir do código-fonte, com overhead de tamanho binário próximo de zero. Só reconsideraremos o libsql-swift se o ecossistema Turso — especificamente o Turso Sync para Swift — se tornar 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 do lançamento, a migração verificará se as contagens de elementos e assets coincidem entre o pacote antigo e o novo banco de dados, e se a recuperação de crash, os limites do WAL e o flush em background passam.
Quando pagar
Essa decisão não é um julgamento sobre bancos de dados. O SQLite lida com gravadores concorrentes, consultas complexas e recuperação de crash em estado compartilhado — nada disso é necessário para o nosso app agora. Pagar por capacidades antes de o app ter os problemas que elas resolvem é uma perda líquida.
Os custos de um banco de dados — a dependência, o gerenciamento do WAL, a camada de adaptação e o tamanho do binário — começam no momento em que a biblioteca é linkada. Os benefícios começam quando o app tem consultas para executar, índices para manter ou gravadores concorrentes para coordenar. Neste estágio, ele não tem nenhum dos três.
Cada passo da evolução do nosso armazenamento só pagará por problemas que já apareceram. Snapshots por página pagam pelo problema que temos hoje: salvar documentos com várias páginas sem reescrever o arquivo inteiro. Se o write amplification se tornar mensurável, um append journal pagará por esse problema. Se busca ou sincronização virar uma necessidade do produto, um banco de dados pagará por esse problema.
A estrutura do pacote, o manifest e o schema do documento não estão presos a nenhum engine de armazenamento. Os custos de troca são baixos porque as fronteiras estão nos lugares certos. Quando chegar o dia em que realmente precisarmos de um banco de dados, vamos adotá-lo para um problema específico e já medido — não para um problema hipotético.