Soft delete sem expurgo é só uma tabela que cresce para sempre. As linhas apagadas pesam nos índices completos, nos backups e nas varreduras, e continuam sendo dado pessoal guardado. Um pedido de eliminação com base na LGPD ou no GDPR não é atendido por uma coluna excluido_em preenchida: o dado continua na tabela, no índice de busca e nas exportações. Defina uma janela de retenção por tabela, o tempo em que restaurar ainda faz sentido, e depois dela apague de verdade.
-- Indice so com os apagados: o expurgo nao varre a tabela inteira
CREATE INDEX CONCURRENTLY usuarios_expurgo_idx
ON usuarios_todos (excluido_em)
WHERE excluido_em IS NOT NULL;
-- Job diario, com o papel de expurgo: apaga de verdade o que passou da janela de retencao.
-- Repita ate afetar zero linhas. SKIP LOCKED evita disputar linha com uma restauracao em curso.
WITH lote AS (
SELECT id
FROM usuarios_todos
WHERE excluido_em < now() - interval '90 days'
ORDER BY excluido_em
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
DELETE FROM usuarios_todos u
USING lote
WHERE u.id = lote.id;
- Apague em lotes pequenos e repita até não sobrar linha. Um DELETE único de milhões de linhas segura locks por minutos, gera um pico de WAL e atrasa as réplicas.
- Os filhos precisam de destino antes do pai: ON DELETE CASCADE, SET NULL ou expurgo próprio. E toda coluna de chave estrangeira que aponta para a tabela precisa de índice, senão cada lote varre as tabelas filhas inteiras.
- Quando a retenção for obrigatória por motivo fiscal ou de auditoria, anonimize em vez de apagar: troque nome e e-mail por valores neutros e mantenha o id, para que o histórico continue íntegro sem identificar a pessoa.
- O expurgo também precisa chegar aos sistemas derivados e respeitar a rotação dos backups. Documente em quanto tempo um dado apagado deixa de existir em todos os lugares.
Por fim, a prova. O teste abaixo conecta com o mesmo papel da aplicação, cria um usuário sentinela, apaga e percorre todas as funções de leitura exportadas pelo módulo de consultas, exigindo que a sentinela não apareça em nenhuma. Uma consulta nova entra na verificação só por ser exportada, sem depender de alguém se lembrar de escrever o teste dela. Os outros dois testes fixam as garantias do banco: o e-mail de um apagado pode ser reutilizado, dois vivos não dividem e-mail e a aplicação não alcança a tabela física.
import test, { after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import * as consultas from './consultas.js'; // toda leitura que a aplicacao expoe
import { excluirUsuario } from './usuarios.js';
// Conecta com o mesmo papel da aplicacao (app_api), nao com o dono do banco.
// Pressupoe as migracoes aplicadas e a empresa 1 criada pelo seed.
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
after(() => pool.end());
async function criarUsuario(empresaId, email) {
const { rows } = await pool.query(
'INSERT INTO usuarios (empresa_id, email, nome) VALUES ($1, $2, $3) RETURNING id',
[empresaId, email, 'Sentinela'],
);
return rows[0].id;
}
test('usuario apagado nao aparece em nenhuma leitura da aplicacao', async () => {
const sentinela = 'sentinela.' + Date.now() + '@exemplo.com';
const id = await criarUsuario(1, sentinela);
await pool.query(
"INSERT INTO turnos (empresa_id, usuario_id, inicio) VALUES (1, $1, now() + interval '1 day')",
[id],
);
assert.equal(await excluirUsuario(pool, 1, id), true);
// Cada funcao exportada de consultas.js recebe (pool, empresaId) e devolve linhas
for (const [nome, consulta] of Object.entries(consultas)) {
const linhas = await consulta(pool, 1);
assert.ok(!JSON.stringify(linhas).includes(sentinela), 'vazou em ' + nome);
}
});
test('e-mail de apagado pode ser recadastrado, mas dois vivos nao dividem e-mail', async () => {
const email = 'carla.' + Date.now() + '@exemplo.com';
const id = await criarUsuario(1, email);
await excluirUsuario(pool, 1, id);
await criarUsuario(1, email); // o recadastro funciona
await assert.rejects(criarUsuario(1, email), { code: '23505' }); // unique_violation
});
test('o papel da aplicacao nao alcanca a tabela fisica', async () => {
// 42501 = insufficient_privilege
await assert.rejects(pool.query('SELECT 1 FROM usuarios_todos LIMIT 1'), { code: '42501' });
});
Rode contra um Postgres real, em contêiner, com as migrações aplicadas e o papel app_api criado. A garantia que se quer provar está nos privilégios e nos índices, e é exatamente isso que um banco em memória ou um mock não reproduz.