Por que o D1 não tem transações (e o que usar no lugar)
O D1 não aceita BEGIN e COMMIT abertos entre requisições. Entenda o motivo e veja como manter seus dados consistentes com db.batch.
Se você já usou Postgres ou MySQL, o primeiro reflexo ao mexer em dois registros ao mesmo tempo é abrir uma transação: BEGIN, faz as coisas, COMMIT. No D1, o banco SQLite da Cloudflare, isso não funciona do jeito que você espera. Vamos ver por quê e o que fazer.
O que é uma transação interativa
Uma transação interativa é aquela em que você abre, volta ao seu código, decide algo com base no que leu e só então fecha:
// Isto NÃO funciona no D1
await db.transaction(async (tx) => {
const [conta] = await tx.select().from(contas).where(eq(contas.id, 1))
if (conta.saldo >= 100) {
await tx.update(contas).set({ saldo: conta.saldo - 100 })
}
})
Entre uma instrução e outra, o banco fica esperando o seu código. Para isso, a conexão precisa ficar presa àquela transação.
Por que o D1 não faz isso
O D1 não é uma conexão fixa a um servidor. Cada consulta do seu Worker viaja pela rede até o banco, e o Worker pode ser executado em qualquer lugar, em várias cópias ao mesmo tempo. Manter uma transação aberta entre duas idas e vindas significaria travar o banco inteiro enquanto o seu código pensa, e uma requisição lenta seria capaz de bloquear todas as outras.
Por isso o D1 só oferece operações que chegam completas ao banco, em um único envio. É uma escolha de projeto, não um defeito.
Alternativa 1: db.batch
Quando você já sabe quais instruções quer rodar, mande todas juntas. O batch executa a lista em uma transação implícita: ou tudo funciona, ou nada é gravado.
await db.batch([
db.insert(pedidos).values({ usuarioId, total }),
db.update(estoque).set({ quantidade: sql`${estoque.quantidade} - 1` }).where(eq(estoque.produtoId, produtoId)),
])
Se a segunda instrução falhar, a primeira é desfeita. Repare que nada aqui depende de um valor lido no meio do caminho.
Alternativa 2: uma instrução condicional
O caso do saldo, onde você lê e depois decide, vira uma única instrução com a condição dentro do próprio SQL:
const resultado = await db
.update(contas)
.set({ saldo: sql`${contas.saldo} - 100` })
.where(and(eq(contas.id, 1), gte(contas.saldo, 100)))
.returning({ id: contas.id })
if (resultado.length === 0) {
throw new Error('Saldo insuficiente')
}
A leitura e a escrita acontecem dentro do banco, de forma atômica. Se o saldo não bastar, nenhuma linha é alterada e o returning volta vazio. Nenhuma outra requisição consegue se meter entre a checagem e o desconto.
Como escolher
- Várias gravações independentes que precisam andar juntas: use
db.batch. - Uma gravação que depende do estado atual: use uma instrução condicional com
whereereturning. - Precisa mesmo de lógica longa no meio? Considere um Durable Object, que processa uma requisição por vez para o mesmo objeto e tem armazenamento próprio.
Um lembrete do template
O Drizzle oferece db.transaction(...), mas com o D1 não conte com ele: a transação interativa não existe no banco. Neste template, prefira sempre db.batch ou uma instrução condicional, e lembre que o D1 é SQLite: não existe transação aninhada.