Dia 13 — Transação no SQLite
BEGIN, COMMIT e ROLLBACK
BEGIN, COMMIT e ROLLBACK
Uma transação é um bloco de gravações que vale inteiro ou não vale nada. O SQLite garante isso com três comandos: BEGIN TRANSACTION abre o bloco, COMMIT confirma, ROLLBACK desfaz.
BEGIN TRANSACTION; INSERT INTO tarefas (titulo, prazo) VALUES (?, ?); UPDATE categorias SET total = total + 1 WHERE id = 1; COMMIT;
A palavra que resume é atomicidade: se a terceira linha falhar, as duas primeiras não podem ter ficado. É o tudo ou nada aplicado a gravações — a unidade é o bloco, e não a linha. É por isso que o comando de fechar é COMMIT e não END — o bloco só vira verdade quando é confirmado.
O agrupar gravação é o que a transação faz com o INSERT e o UPDATE do exemplo: dois comandos que escrevem em tabelas diferentes passam a contar como uma operação só. Sem o agrupamento, a tarefa existe e a contagem da categoria não existe, e o banco fica mentindo sobre uma das duas.
O que fica dentro do bloco é decisão de aplicação, e o critério é o que acontece se o bloco falhar no meio. Se cada comando é útil sozinho, cada comando fica fora. Se a primeira gravação só faz sentido junto com a segunda, as duas entram juntas.
O ROLLBACK no caminho do erro
O caminho do erro é o mesmo bloco pelo outro lado. A transação se decide desfazer lançando o erro de dentro do transaction — não existe ROLLBACK escrito à mão: a biblioteca abre e fecha o bloco, e o erro é o sinal.
await banco.transaction((tx) => { for (const item of lote) { tx.executeSql('INSERT INTO itens (nome) VALUES (?)', [item.nome]); } if (faltandoCampos(lote)) { throw new Error('lote com campo obrigatorio vazio'); } });
O throw não é decorativo: sem ele a falha é engolida e o aplicativo mostra "importado com sucesso" para um lote que não entrou. É a mesma armadilha do catch vazio do INSERT, com a diferença de que aqui a falha é de vários comandos e o aplicativo precisa saber.
O cb, o callback que o transaction recebe, é a função que roda dentro do bloco — e é dela que o throw sai. O nome cb é abreviação de "callback", que é apenas o nome que o React Native dá ao parâmetro que recebe a função. A regra é a mesma: o que decide o destino do bloco é o erro lançado dentro dela.
Quando se escreve tx.executeSql('BEGIN TRANSACTION') dentro do transaction, o banco recebe dois pedidos de abertura e responde cannot start a transaction within a transaction. A transação já está aberta: o transaction é quem abre, quem confirma e quem desfaz.
O erro no meio é o que o exemplo demonstra, e ele mostra o mesmo lote nas duas situações. Na transação, a terceira linha viola o NOT NULL do título e o log registra o ROLLBACK com o arquivo em zero linhas e nenhuma escrita no disco. Fora da transação, o mesmo lote falha depois de três linhas e as três ficam — o que é a falha no meio sem atomicidade, o estado intermediário que o banco nunca deveria ter.
A escrita no arquivo acontece no COMMIT
Durante o bloco, as gravações ficam pendentes: o motor guardou o que precisa mudar, mas ainda não escreveu no arquivo. Só no COMMIT ele escreve. Duas consequências práticas: um lote de mil INSERT dentro da transação custa uma escrita no disco, não mil; e um COMMIT é o ponto em que o dado fica realmente salvo — se o aparelho morrer antes dele, o ROLLBACK implícito do motor deixa o arquivo como estava.
O exemplo conta as escritas no disco como um número, e a diferença entre os dois caminhos aparece nele: a transação que confirma uma vez tem uma escrita; a que falha tem zero. O número é a prova de que o COMMIT é o momento que importa, e não cada INSERT individualmente.
O custo é o bloqueio. Entre BEGIN e COMMIT o banco não aceita outra escrita, e por isso uma transação não deve ficar aberta esperando rede, relógio ou toque do usuário. O que é rápido fica dentro; o que espera, fica fora.
Exemplo
// `BEGIN TRANSACTION`, `COMMIT` e `ROLLBACK`: um bloco de gravacoes // que vale inteiro ou nao vale nada. // // O exemplo implementa o SQL puro de proposito, para o mecanismo ficar // visivel. Na biblioteca `banco.transaction((tx) => { ... })` e quem // executa os tres comandos: a sua funcao nao escreve `BEGIN`, e o erro // e lancado de dentro dela para pedir o `ROLLBACK`. Ver a aula. function criarBanco() { return { tarefas: [], categorias: [{ id: 1, nome: 'estudo', total: 0 }], escritasNoDisco: 0, // o metodo que recebe o texto do comando; aqui ele so registra o // que foi pedido, porque quem executa e o proprio exemplo executeSql(comando) { return [{ comando: comando }]; }, }; } function inserir(banco, linha) { if (!linha.titulo) throw new Error('NOT NULL constraint failed: titulo'); linha.id = banco.tarefas.length + 1; banco.tarefas.push(linha); return linha.id; } function transacionar(banco, operacoes) { banco.executeSql('BEGIN TRANSACTION'); const pendente = []; try { for (const operacao of operacoes) { pendente.push(operacao(banco)); } banco.executeSql('COMMIT'); banco.escritasNoDisco += 1; return { commit: true, resultado: pendente }; } catch (falha) { banco.executeSql('ROLLBACK'); banco.tarefas = banco.tarefas.slice(0, banco.tarefas.length - pendente.length); throw falha; } } const lote = [ { titulo: 'Revisar o WHERE', prazo: '2026-09-10' }, { titulo: 'Ler o capitulo 4', prazo: '2026-09-12' }, { titulo: 'Enviar o relatorio', prazo: '2026-09-08' }, ]; // 1. o caminho feliz: as tres gravacoes entram const banco = criarBanco(); const ok = transacionar(banco, [ (b) => inserir(b, lote[0]), (b) => inserir(b, lote[1]), (b) => inserir(b, lote[2]), ]); console.log('COMMIT: commit =', ok.commit, '| ids gerados:', ok.resultado.join(', ')); console.log('linhas no arquivo:', banco.tarefas.length, '| escritas no disco:', banco.escritasNoDisco); // 2. o caminho do erro: a terceira falha e nada fica const banco2 = criarBanco(); try { transacionar(banco2, [ (b) => inserir(b, lote[0]), (b) => inserir(b, lote[1]), (b) => inserir(b, { titulo: '', prazo: '2026-09-09' }), ]); } catch (falha) { console.log('\nROLLBACK:', falha.message); } console.log('linhas no arquivo depois do ROLLBACK:', banco2.tarefas.length); console.log('escritas no disco:', banco2.escritasNoDisco, '- nada chegou a ser gravado'); // 3. o mesmo lote fora da transacao: a falha deixa metade const banco3 = criarBanco(); let gravadas = 0; try { for (const linha of [...lote, { titulo: '', prazo: '2026-09-09' }]) { inserir(banco3, linha); gravadas += 1; } } catch (falha) { console.log('\nsem transacao, falhou apos', gravadas, 'linhas'); } console.log('sobrou no arquivo:', banco3.tarefas.map((l) => l.titulo)); // 4. o `throw` depois do `ROLLBACK` evita o "importado com sucesso" function semThrow(banco) { try { transacionar(banco, [(b) => inserir(b, { titulo: '', prazo: '2026-09-09' })]); return 'importado com sucesso'; } catch (falha) { return 'falhou: ' + falha.message; } } console.log('\ncom throw no catch:', semThrow(criarBanco()));
Saída real
COMMIT: commit = true | ids gerados: 1, 2, 3 linhas no arquivo: 3 | escritas no disco: 1 ROLLBACK: NOT NULL constraint failed: titulo linhas no arquivo depois do ROLLBACK: 0 escritas no disco: 0 - nada chegou a ser gravado sem transacao, falhou apos 3 linhas sobrou no arquivo: [ 'Revisar o WHERE', 'Ler o capitulo 4', 'Enviar o relatorio' ] com throw no catch: falhou: NOT NULL constraint failed: titulo
Quando a transação salva o app
Quando a transação salva o app
A transação é o que impede o aplicativo de gravar um estado intermediário — um estado em que a tela faria sentido se visse, mas que nunca deveria existir. Isso acontece sempre que a operação tem mais de uma parte que precisa ser coerente entre si.
O caso clássico é o da contagem. Se a tabela de tarefas e a tabela de categorias guardam o mesmo dado de duas formas, gravar uma sem a outra deixa o banco mintindo:
async function registrarTarefaComCategoria(banco, titulo, prazo, categoriaId) { return banco.transaction((tx) => { tx.executeSql('INSERT INTO tarefas (titulo, prazo, categoria_id) VALUES (?, ?, ?)', [titulo, prazo, categoriaId]); tx.executeSql('UPDATE categorias SET total = total + 1 WHERE id = ?', [categoriaId]); }); }
Sem a transação, falha no UPDATE significa tarefa criada sem a contagem — e a contagem errada não aparece como erro, aparece como número que não fecha. Com a transação, os dois comandos valem ou nenhum.
É a atomicidade na prática: o defeito que a transação evita não é o erro visível, e sim o dado que parece correto e não está. Uma contagem errada não quebra o aplicativo — ela aparece semanas depois, num relatório que não bate com a lista, e ninguém lembra do UPDATE que falhou.
O exemplo desta página mostra os dois caminhos com o mesmo erro: sem transação, a tarefa é gravada e a contagem fica em zero, com o banco contando uma tarefa cuja categoria não existe. Com transação, nem a tarefa nem a contagem entram. O dado inconsistente do primeiro caminho é o que a segunda linha de código impede.
Repare que não há BEGIN TRANSACTION, nem COMMIT, nem ROLLBACK escritos no código. Quem abre, confirma e desfaz o bloco é a própria biblioteca, e quem fala "isto falhou, não grava" é o throw. Escrever o BEGIN à mão aqui produz cannot start a transaction within a transaction: o transaction já abriu a transação antes de chamar a sua função.
Importação com estado intermediário
O segundo caso é a importação de um backup. O arquivo tem duzentas tarefas; o banco tem cinquenta. A importação tem que ser consistente do ponto de vista do usuário: ou o banco fica com as duzentas, ou fica com as cinquenta. Nada de ficar com cento e vinte e nenhuma indicação de por quê.
É a transação em import — e é ela que resolve o problema mais caro do aplicativo, que é a importar dados que chega pela metade. O caso do gravar em lote tem a mesma origem: um conjunto grande de comandos que só faz sentido junto. A falha parcial é o nome do que acontece sem o agrupamento — duzentas tarefas importadas, cento e oitenta e cinco no arquivo, nenhuma indicação de quais faltaram.
É a transação que dá essa garantia, e ela acompanha o DELETE do que já estava:
// importa o backup inteiro: apaga o que ha e grava o que vem await banco.transaction((tx) => { tx.executeSql('DELETE FROM tarefas'); for (const tarefa of backup) { tx.executeSql('INSERT INTO tarefas (...) VALUES (...)', [/* valores */]); } if (backup.length < esperaMinima) { throw new Error('backup truncado: refusing to replace the data'); } });
O throw depois do DELETE é o que protege o pior caso: um arquivo de backup corrompido não pode terminar com o banco apagado. Ele está no fim de propósito — as validações que podem reprovar o lote devem rodar antes da primeira gravação, e a que depende do resultado da gravação fica no fim, onde o throw ainda desfaz.
A ordem da validação é a mesma que a do formulário do dia 12, com o risco maior: aqui, um throw tardio desfaz um DELETE de dados que já estavam lá. O exemplo fecha o ciclo com as duas importações — a que funciona, deixando o banco com as linhas do backup, e a que falha na categoria de um dos registros, deixando o banco exatamente como estava antes de começar.
Leitura dentro da transação
Uma transação também dá um retrato estável: o SELECT que roda dentro dela vê o banco como estava no BEGIN, mesmo que outra escrita chegue no meio. Isso importa em duas telas abertas ao mesmo tempo, e é o que permite calcular "se eu inserir esta linha, o total passa a ser X" com um SELECT seguido do INSERT, sem que outra tela mude o total no intervalo.
É a transação com SELECT: a leitura faz parte do mesmo bloco e enxerga o mesmo estado. A utilidade aparece nos cálculos que dependem do valor atual — "quantas tarefas faltam para o limite", "qual é o próximo número", "o total passa a ser quanto". Sem a transação, o valor lido e o valor gravado podem pertencer a instantes diferentes, e a conta não fecha.
O que não é transação é consistência entre o banco e a tela: o React não sabe que houve COMMIT. Por isso a tela recarrega depois de gravar — e é por isso que recarregar mora no hook de leitura, e não em cada tela que grava.
Exemplo
// A transacao impede o banco de ficar num estado intermediario: com ela, // a tarefa e a contagem da categoria entram juntas, ou nenhuma entra. function criarBanco() { return { tarefas: [], categorias: [{ id: 1, nome: 'estudo', total: 0 }] }; } function registrarTarefaComCategoria(banco, titulo, prazo, categoriaId) { banco.gravacoesPendentes = []; let categoria = null; try { banco.tarefas.push({ id: banco.tarefas.length + 1, titulo: titulo, prazo: prazo, categoria_id: categoriaId }); banco.gravacoesPendentes.push('tarefa'); categoria = banco.categorias.find((c) => c.id === categoriaId); if (!categoria) throw new Error('FOREIGN KEY constraint failed: categoria_id'); categoria.total = categoria.total + 1; banco.gravacoesPendentes.push('contagem'); return { commit: true, tarefas: banco.tarefas.length, total: categoria.total }; } catch (falha) { if (banco.gravacoesPendentes.includes('tarefa')) banco.tarefas.pop(); if (banco.gravacoesPendentes.includes('contagem')) categoria.total = categoria.total - 1; throw falha; } } const banco = criarBanco(); const ok = registrarTarefaComCategoria(banco, 'Revisar o WHERE', '2026-09-10', 1); console.log('COMMIT ->', JSON.stringify(ok)); console.log('tarefas:', banco.tarefas.map((t) => t.titulo), '| total da categoria:', banco.categorias[0].total); const banco2 = criarBanco(); try { banco2.tarefas.push({ id: 1, titulo: 'Revisar o WHERE', prazo: '2026-09-10', categoria_id: 99 }); const categoria = banco2.categorias.find((c) => c.id === 99); if (!categoria) throw new Error('FOREIGN KEY constraint failed: categoria_id'); categoria.total = categoria.total + 1; } catch (falha) { console.log('\nsem transacao ->', falha.message); } console.log('tarefa gravada:', banco2.tarefas.length, '| total da categoria:', banco2.categorias[0].total); console.log('o banco ficou contando uma tarefa que a categoria nao tem'); const banco3 = criarBanco(); try { registrarTarefaComCategoria(banco3, 'Outra tarefa', '2026-09-11', 99); } catch (falha) { console.log('\ncom transacao ->', falha.message); } console.log('tarefas:', banco3.tarefas.length, '| total da categoria:', banco3.categorias[0].total); function importar(banco, backup) { const antes = { tarefas: [...banco.tarefas], categorias: banco.categorias.map((c) => ({ ...c })) }; try { banco.tarefas = []; banco.categorias = banco.categorias.map((c) => ({ ...c, total: 0 })); for (const linha of backup) { registrarTarefaComCategoria(banco, linha.titulo, linha.prazo, linha.categoria_id); } return { commit: true, tarefas: banco.tarefas.length }; } catch (falha) { banco.tarefas = antes.tarefas; banco.categorias = antes.categorias; throw falha; } } const backupBom = [ { titulo: 'Backup A', prazo: '2026-09-10', categoria_id: 1 }, { titulo: 'Backup B', prazo: '2026-09-11', categoria_id: 1 }, ]; const backupRuim = [...backupBom, { titulo: 'Backup C', prazo: '2026-09-12', categoria_id: 77 }]; const banco4 = criarBanco(); registrarTarefaComCategoria(banco4, 'Tarefa que ja existia', '2026-09-01', 1); console.log('\nimportacao completa ->', JSON.stringify(importar(banco4, backupBom))); console.log('o banco ficou com', banco4.tarefas.map((t) => t.titulo)); const banco5 = criarBanco(); registrarTarefaComCategoria(banco5, 'Tarefa que ja existia', '2026-09-01', 1); try { importar(banco5, backupRuim); } catch (falha) { console.log('importacao com falha ->', falha.message); } console.log('o backup nao substituiu nada:', banco5.tarefas.map((t) => t.titulo));
Saída real
COMMIT -> {"commit":true,"tarefas":1,"total":1}
tarefas: [ 'Revisar o WHERE' ] | total da categoria: 1
sem transacao -> FOREIGN KEY constraint failed: categoria_id
tarefa gravada: 1 | total da categoria: 0
o banco ficou contando uma tarefa que a categoria nao tem
com transacao -> FOREIGN KEY constraint failed: categoria_id
tarefas: 0 | total da categoria: 0
importacao completa -> {"commit":true,"tarefas":2}
o banco ficou com [ 'Backup A', 'Backup B' ]
importacao com falha -> FOREIGN KEY constraint failed: categoria_id
o backup nao substituiu nada: [ 'Tarefa que ja existia' ]