fix: goals.investment_type_id ganha FK ON DELETE SET NULL (closes #65) - #105
fix: goals.investment_type_id ganha FK ON DELETE SET NULL (closes #65)#105Guiroos wants to merge 4 commits into
Conversation
Excluir um tipo de investimento deixava metas vinculadas com um uuid órfão em investmentTypeId — a coluna era a única uuid não-PK do schema sem .references(), e nenhuma migration criava a constraint no banco. getGoalsWithProgress entrava no ramo de saldo vinculado (id truthy) e somava 0, travando a meta em R$ 0,00/0% sem erro nem indicação na UI. Migration nulifica órfãos pré-existentes antes do ADD CONSTRAINT. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUxAA67srmEXVPBcZAa7Es
Guiroos
left a comment
There was a problem hiding this comment.
Um achado, na ordem de merge — o código em si está limpo.
O teste de integração novo depende de a 0017 estar aplicada no branch pai do Neon, e o job integration só roda em push para main: se a migration não subir para o branch dev antes do merge, a falha aparece com o main já vermelho. Detalhe e correção no comentário inline.
Verificações que fiz e passaram: drizzle-kit check, drizzle-kit generate (sem drift schema↔snapshot), prettier --check lib/db/migrations/, tsc --noEmit, 490 testes unitários.
Generated by Claude Code
| describe('deleteInvestmentType', () => { | ||
| it('nulifica investmentTypeId de metas vinculadas em vez de deixar id órfão', async () => { | ||
| const type = await createInvestmentType(db, userId, { name: 'Tipo para Deletar' }) | ||
| const goal = await createGoal(db, userId, { | ||
| name: 'Meta Vinculada', | ||
| investmentTypeId: type.id, | ||
| }) | ||
|
|
||
| const { deleteInvestmentType } = await import('@/lib/actions/investments') | ||
| await deleteInvestmentType(type.id) | ||
|
|
||
| const savedGoal = await db.query.goals.findFirst({ | ||
| where: eq(schema.goals.id, goal.id), | ||
| }) | ||
| expect(savedGoal).toBeDefined() | ||
| expect(savedGoal?.investmentTypeId).toBeNull() |
There was a problem hiding this comment.
Bloqueante para a ordem de merge (não é defeito do código): este teste só passa depois que a 0017 estiver aplicada no branch pai do Neon (NEON_PARENT_BRANCH_ID), e nada neste PR faz isso acontecer.
Por quê: __tests__/integration/setup.ts monta o makeNeonTesting com parentBranchId, e o branch de teste é um clone do pai — .claude/testing.md é explícito em "Branches são clones — sem migrations: o branch de teste herda o schema completo do pai; nunca chamar migrate() nos testes". O vercel.json roda db:migrate contra a DATABASE_URL de produção, não contra o branch dev. Ou seja: nenhum caminho automático leva a 0017 até o schema que o teste enxerga.
O modo de falha é o mesmo que .claude/testing.md já registra para onConflictDoUpdate ("se o unique index não existir no banco, o clause nunca dispara"): sem a constraint no banco de teste, deleteInvestmentType apaga o tipo normalmente, goals.investment_type_id continua com o id órfão, e a linha 419 falha com o UUID em vez de null. E como o job integration do CI tem if: github.event_name == 'push' && github.ref == 'refs/heads/main', a falha não aparece aqui no PR — ela aparece depois do merge, com o main vermelho, que é o pior lugar para descobrir.
Correção concreta, antes do merge: aplicar a migration no branch dev que serve de pai (DATABASE_URL=<connection string do branch dev> npm run db:migrate, ou via console do Neon) e registrar isso na descrição do PR junto da nota que já existe sobre produção — hoje a seção "Risco e o que NÃO foi coberto" cobre só o lado de produção (db:migrate antes de mutação), e o branch de teste é a outra metade da mesma exigência.
Resto do PR conferido e sem achado: drizzle-kit check → "Everything's fine"; drizzle-kit generate não acusa drift entre schema.ts e o 0017_snapshot.json; prettier --check lib/db/migrations/ verde (o passo do .claude/db.md foi feito); tsc --noEmit e os 490 testes unitários verdes; o UPDATE de limpeza é idempotente e o NOT IN não tem risco de NULL porque investment_types.id é PK. A ordem de resetAccount (investmentTypes na linha 79, goals na 80) continua válida com o SET NULL novo — as metas são nuladas e apagadas em seguida.
Generated by Claude Code
There was a problem hiding this comment.
Achado correto — confirmei a cadeia (setup.ts → parentBranchId, .claude/testing.md sobre branches serem clone sem migration, vercel.json só migrando produção). Não é algo que eu consiga corrigir com um push: esta sessão não tem credenciais do branch dev do Neon (NEON_PARENT_BRANCH_ID) para rodar db:migrate contra ele.
Atualizei a descrição do PR: a seção "Risco e o que NÃO foi coberto" agora tem uma entrada dedicada a isso, marcada como bloqueante de ordem de merge, com o comando exato (DATABASE_URL=<branch dev> npm run db:migrate) para quem tiver acesso rodar antes do merge.
Generated by Claude Code
Guiroos
left a comment
There was a problem hiding this comment.
Revisão do head atual (a3fe7ee, depois dos dois merges de main). Um achado novo, não bloqueante, no inline em lib/db/schema.ts.
Resumo: a FK faz deleteInvestmentType passar a mutar goals, e ela revalida só /investimentos. Não é preciosismo de cache — a nulificação troca o ramo de cálculo de getGoalsWithProgress, então uma meta com contribuições manuais sai de R$ 0,00 para o valor real, que é o ganho da #65. Detalhe, cenário de reprodução e o assert discriminante no inline.
O que verifiquei nesta rodada e não virou achado:
- A coluna é viva, não vestígio.
goals.investmentTypeIdé lida emlib/queries/goals.ts:44,104-107,142e escrita emlib/actions/goals.ts:29-39— a FK vale para uma relação que a UI usa de fato, não para uma coluna morta. ON DELETE SET NULLnão conflita com a direção inversa.investmentTypes.goalIdjá éset nullparagoals.id; as duas pontas nulificam, nenhuma cascateia, então não há caminho de delete em cadeia.resetAccountedeleteAccountseguem válidos. NoresetAccounta ordem (investmentTypesantes degoals) só ganha umUPDATEintermediário dentro da mesma transação. NodeleteAccount,goals.userIdécascadee o novo FK éset null— não é do tipo que faria oDELETE FROM usersestourar violação, que é o que__tests__/integration/actions-delete-account.test.tsexiste para pegar.- Padrão
AnyPgColumn+ função lazy é o certo aqui, e pelo motivo declarado no corpo do PR (referência mútua entre as duas tabelas) — mesmo caso do self-FK dedebtorEntries.settledByPaymentIdem.claude/db.md. .sqlsem newline final não quebra oformat:check— o Prettier não parseia.sqlsem plugin, e o passo do.claude/db.md(prettier --write lib/db/migrations/meta/) foi feito.
O bloqueante de ordem de merge da rodada anterior (aplicar a 0017 no branch pai do Neon antes do merge, senão integration falha com o main já vermelho) continua de pé — a descrição do PR passou a registrá-lo, mas a thread segue aberta de propósito, porque a ação em si depende de credenciais que nenhuma sessão aqui tem.
Generated by Claude Code
Com a FK nova (goals.investmentTypeId -> investmentTypes.id ON DELETE SET NULL), deleteInvestmentType passou a mutar linhas de goals — e a nulificação troca o ramo de cálculo de getGoalsWithProgress (vinculado -> manual, somando goalContributions em vez de amountsByType). Sem revalidar /metas, uma meta que tinha contribuições manuais e ficava travada em R$ 0,00 continua mostrando o valor antigo no Router Cache até a próxima navegação forçada. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUxAA67srmEXVPBcZAa7Es
O que mudou
lib/db/schema.ts—goals.investmentTypeIdganha.references((): AnyPgColumn => investmentTypes.id, { onDelete: 'set null' }). Era a única colunauuidnão-PK do schema sem FK declarada.lib/db/migrations/0017_dizzy_the_leader.sql— nova migration: primeiro nulifica qualquergoals.investment_type_idjá órfão (UPDATE ... WHERE investment_type_id NOT IN (SELECT id FROM investment_types)), depois cria a constraintON DELETE SET NULL.__tests__/integration/actions-investments.test.ts— novodescribe('deleteInvestmentType'): cria um tipo sem nenhum registro mensal (evita que orestrictdeinvestments/investmentWithdrawalsmascare o teste), cria uma meta vinculada a ele, chama a action realdeleteInvestmentType, e assere quegoals.investment_type_idficanullno banco (não só que a query não lança).Por que dessa forma
goals.investmentTypeIdreferenciavainvestmentTypes, uma tabela declarada 11 linhas depois no mesmo arquivo — einvestmentTypes.goalIdjá referenciagoals.idde volta (ON DELETE SET NULL). É uma referência mútua entre as duas tabelas, o mesmo problema de inferência circular de tipos que o self-FK dedebtorEntries.settledByPaymentIdresolve — por isso segui o mesmo padrão documentado em.claude/db.md(AnyPgColumn+ função lazy), mesmo a issue não tendo citado esse detalhe explicitamente.tsc --noEmitconfirma que resolve sem erro.A política
set null(nãorestrict/cascade) é a que a issue já justifica: espelha a direção inversa da mesma relação (investment_types_goal_id_goals_id_fk), evita apagar a meta do usuário junto com o tipo, e evita transformar o delete numa segunda falha em runtime com mensagem de erro que descreveria outra causa (RowActionsdoInvestmentTypeCard/InvestmentTypeAccordionmostra "não é possível excluir — tipo em uso").Não adicionei nenhum
update(goals)manual emdeleteInvestmentType— com a FK no lugar o banco resolve, e não há estado dependente do id (diferente do casodeleteDebtEntry, onde oSET NULLnão resetastatus).Como testei
npm run test:integrationnão rodei — exige credenciais do Neon e só roda em push paramainno CI. O teste novo (deleteInvestmentType) está escrito e segue o padrão de mock (requireUserId+next/cache) já usado no resto do arquivo; ele valida a asserção que só a correção certa passa:goal.investmentTypeId === nullno banco após o delete real do tipo, não apenas ausência de erro.Risco e o que NÃO foi coberto
0017estiver aplicada no branch pai do Neon (NEON_PARENT_BRANCH_ID), porque o branch de teste é clone do pai e nunca rodamigrate()(.claude/testing.md). Overcel.jsonsó rodadb:migratecontra aDATABASE_URLde produção — nenhum caminho automático aplica0017no branch dev. Sem isso,integrationfalha depois do merge, commainvermelho, e não aqui no PR (o job só roda em push paramain). Esta sessão não tem credenciais do Neon dev branch para aplicar a migration — antes de mergear, alguém com acesso precisa rodarDATABASE_URL=<connection string do branch dev> npm run db:migrate(ou aplicar via console do Neon).0017_dizzy_the_leader.sql) também precisa rodar vianpm run db:migrateantes de qualquer mutação em produção — se aplicada antes do deploy do código (fluxo padrão dovercel.json), sem risco: oUPDATEde limpeza é idempotente e no-op quando não há órfãos.InvestmentTypeCard/InvestmentTypeAccordion/MetasList/GoalDialog) — a issue não pede isso; com a FK, a meta simplesmente passa a mostrar o estado "sem tipo vinculado" (mesmo comportamento hoje reservado a metas manuais) depois do delete, em vez de ficar travada em R$ 0,00 com id órfão invisível.npm run test:integrationnão roda nesta sessão (exigeNEON_API_KEY/NEON_PROJECT_IDetc.) — fica para o CI em push paramain.Arquivos tocados
lib/db/schema.tslib/db/migrations/0017_dizzy_the_leader.sql(novo)lib/db/migrations/meta/0017_snapshot.json(novo, gerado)lib/db/migrations/meta/_journal.json__tests__/integration/actions-investments.test.ts