Transações e savepoints¶
Problema: algumas operações só fazem sentido juntas — debitar de uma conta e creditar em outra, criar um pedido e baixar o estoque. Se a segunda falha, a primeira não pode ficar de pé.
Solução: engine.transaction(fn) roda seu bloco numa transação: commit se tudo
der certo, rollback automático se qualquer coisa lançar. Pra rollback parcial, há
beginNested (savepoints).
A teoria em uma frase¶
transaction(fn) faz BEGIN, roda fn, e então COMMIT — mas se fn lança, faz
ROLLBACK e re-lança o erro. Você nunca precisa lembrar de fechar a transação na mão.
Tudo ou nada¶
import { createEngine, insert, update } from "tempest-db-js";
const engine = createEngine("sqlite:///bank.db");
await engine.transaction(async (tx) => {
await tx.execute(update(Account).set({ balance: 90 }).where({ id: 1 }));
await tx.execute(update(Account).set({ balance: 110 }).where({ id: 2 }));
// chegou aqui sem lançar → COMMIT automático
});
Se a segunda linha lançar (saldo insuficiente, violação de constraint, etc.), a primeira é desfeita — as duas contas voltam ao estado anterior.
Não capture o erro silenciosamente dentro do bloco
O rollback é disparado pela exceção propagando pra fora de fn. Se você
try/catch tudo lá dentro e engole o erro, o transaction acha que deu certo e
faz commit. Deixe o erro subir (ou re-lance) quando quiser abortar.
Savepoints: rollback parcial¶
beginNested cria um savepoint — uma sub-transação que pode reverter sozinha sem
derrubar a transação externa. Útil pra "tente isso; se falhar, siga sem ele":
await engine.transaction(async (tx) => {
await tx.execute(insert(Order).values({ userId: 1, total: "100.00" }));
try {
await tx.beginNested(async (sp) => {
await sp.execute(insert(Coupon).values({ orderId: 1, code: "INVALIDO" }));
// se isso violar uma constraint, só ESTE savepoint reverte
});
} catch {
// o cupom falhou, mas o pedido continua de pé — seguimos sem desconto
}
await tx.execute(update(Order).set({ status: "confirmado" }).where({ id: 1 }));
// COMMIT: pedido criado + confirmado; cupom descartado
});
Sync também tem transação
Em SQLite síncrono (createSyncEngine), engine.transaction((tx) => { ... })
funciona igual — sem await. Ótimo pra seeds e scripts.
Blocos aninhados: um COMMIT só¶
transaction() é re-entrante. Um bloco aberto dentro de outro, na mesma sessão,
adere ao de fora: um BEGIN, um COMMIT, e a falha interna derruba tudo.
const session = engine.session();
const orders = new BaseRepository(Order, session);
const items = new BaseRepository(Item, session);
await session.transaction(async () => {
await orders.create({ id: 1, total: 10 }); // sem COMMIT aqui
await items.createMany(lines); // nem aqui
}); // um COMMIT no fim
Isso é o que faz um service que orquestra vários repositories funcionar: todos seguram
a mesma sessão, então todos enxergam o mesmo bloco aberto. Sem re-entrância, o
segundo transaction() emitiria um BEGIN dentro de outro.
Aninhado não é recuperável — savepoint é
Falha em bloco aninhado derruba o bloco inteiro, inclusive o trabalho de fora.
Para recuperar de uma falha interna sem descartar o resto, use beginNested, que é
SAVEPOINT de verdade:
await session.transaction(async (tx) => {
await orders.create(order); // fica
await tx.beginNested(async (sp) => {
await risky(sp); // se falhar, só isto volta
}).catch(logAndContinue);
await audit.create(entry); // continua rodando
});
beginNested precisa de um bloco aberto — o PostgreSQL recusa savepoint fora de
transação.
Nível de isolamento¶
O nível é pedido no bloco, não no engine:
await session.transaction(
async (tx) => {
await claimBatch(tx);
},
{ isolation: "serializable" },
);
| Nível | PostgreSQL | SQLite | MySQL |
|---|---|---|---|
read uncommitted |
aceita (age como read committed) |
erro | aceita |
read committed |
default | erro | aceita |
repeatable read |
aceita | erro | default |
serializable |
aceita | único que existe | aceita |
O SQLite roda um escritor por vez, então o único nível que ele tem é serializable —
não há sintaxe para pedir outro nem nível mais fraco para onde cair. Pedir outro
lança, porque quem escreveu repeatable read estava raciocinando sobre uma
garantia.
Bloco somente-leitura¶
BEGIN ... READ ONLY no PostgreSQL, START TRANSACTION READ ONLY no MySQL: o banco
recusa a escrita, não a aplicação. No SQLite lança — PRAGMA query_only é por conexão,
não por transação.
Serializable devolve erro, e o erro é normal
Sob serializable o PostgreSQL aborta uma das transações concorrentes com
could not serialize access (SQLSTATE 40001). Isso não é bug: é o banco dizendo
que as duas juntas violariam a serialização. O chamador precisa repetir a
transação — sem retry, serializable só troca dado errado por erro.
Característica só no bloco mais externo
Isolamento é fixado quando a transação abre. Pedir num bloco aninhado (que adere ao de fora) lança, em vez de fingir que foi aplicado.
Recap¶
engine.transaction(fn)→COMMITno sucesso,ROLLBACKsefnlançar.- Deixe o erro propagar pra abortar — engolir o erro causa commit indevido.
tx.beginNested(fn)→ savepoint; reverte só a sub-transação.- Vale pra async (
createEngine) e sync (createSyncEngine).