Dia 13 — Transação no SQLite

Informatica · Conteudo · publicado em 05/10/2026
Dia 13 de 15

Transação no SQLite

Aula 1

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
Aula 2

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' ]