Freshness audit confirmou que perf de import/lista de apoiadores já estava entregue em main desde 2026-07-19; atualiza status do plano e CHANGELOG-AGENTS. Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
22 KiB
Escala e DRY pós-C6 (apoiadores / import / listas)
Status: F1–F2 entregues (2026-07-19, código em main; plano fechado 2026-08-01 Issue #37); F4 (DRY de forms) entregue — twin praça 2026-07-21 + hardening 2026-07-23; o resto do F4 (10 escadas hand-rolled escritas depois) fechou em 2026-07-25 no Pass 2 W4d (runCampaignFormAction, exceções documentadas em comentário); F3 (parsers das listas de entidade) fechou no Pass 2 W1-D2; F4b entregue 2026-07-27 (guard ampliado para *FormActions.ts + taskActions.ts; 6 irmãos migrados; taskActions.ts allowlisted); F3 (helpers DRY restantes — CampaignListEmptyState, firstValue local) com gatilho
Atualizado em: 2026-08-01 (F1–F2 fechados — Issue #37)
Item do roadmap: docs/roadmap.md (Fill-ins abertos, item C8)
Responsável: —
Revisão 2026-07-24: a remodelagem M1 renomeou
plaza*/nucleus*→municipality*(pathspracas/→municipios/), e o hardening 2026-07-23 (Fase 5) completou a Fase 4: os 4 ladders*/novamigraram pararunCampaignRedirectFormAction, os demaisformActionsjá usammapCampaignFormActionError+campaignFormFields(única exceção:planos/[slug]/lifecycleFormActions.ts, que só retorna safe-messages fixas — sem ladder para migrar), eCampaignInviteFormjá importafieldError/errorPropsdecampaignFormFields. O split de access também entregou parte da F3:getAdvisorMunicipalityIds(src/utilities/access/municipalities.ts) é a variante semPayloadRequest, com cache request-scoped por contexto, esupporterListOverviewAggregaterecebe os IDs pré-carregados.Revisão 2026-08-01 (Issue #37 — freshness audit): F1–F2 já estavam implementados desde 2026-07-19 (
1e23b27) e sobreviveram ao Pass 3 (módulos emsrc/utilities/supporter/). As-built confirmado: locks em batch viaacquireTextAdvisoryLocks, bulk com.returning()+ leituras drizzle, overview derivandototaldetotalDocs, migrationpg_trgm,contactSearchQuerynos filtros,advisorMunicipalityIdspré-carregados emloadSupportersPageData. Int specs:campaignSupporter.int.spec.ts,postgresTransactionLocks.int.spec.ts,supporterListFilters.int.spec.ts. Follow-ups de filtros/forms → C9 (escala-dry-pos-c8.md).
Contexto
O C6 (escala-dry-pos-c2.md) entregou a escala do cadastro nominal de apoiadores: import bulk via drizzle na txn Payload, KPI agregado em SQL, token HMAC de import single-use, e shells compartilhados (campaignListUrl, CampaignListPagination, campaignFormFields, mapCampaignFormActionError). A passagem /simplify de 2026-07-19 aplicou limpezas pontuais (identity maps, dead branches, relationshipId, base64url nativo, rowCount ?? 0, reuso de canManageCampaignUsers) mas deixou de fora débitos que os revisores (quality / reuse / performance) marcaram como importantes e maiores que cleanup. Este plano é o registro canônico desses follow-ups — precedente direto de B5 (pós-B2) e C7 (pós-C3).
Sem este item, a importação funciona em volume baixo, mas: (a) o gargalo de import em escala volta a ser a aquisição de locks advisory (um round-trip por telefone), não o insert bulk; (b) a página de lista de apoiadores faz dois COUNTs filtrados por navegação; (c) a busca por nome/cidade é ILIKE '%q%' não-sargable; (d) o scaffold de tipos drizzle do bulk apaga a tipagem de colunas (já causou um bug contactId → contact); (e) o helper de erro de form adotado só por 2 forms permanece duplicado em ~6 formActions legados.
Objetivos
- Import bulk de 5k apoiadores adquire todos os locks advisory de telefone em um round-trip (hoje até 5k round-trips sequenciais).
- O insert bulk de contacts recupera IDs via
.returning()(hoje re-payload.findpor chunk). - Leituras de existência (contacts/supporters existentes) no bulk usam drizzle direto na mesma txn, sem o scaffolding do Local API.
- A página de lista de apoiadores não faz dois COUNTs filtrados por navegação; o
totaldo overview vem dototalDocsda lista, ou o overview é cacheado por(user, filterState)com TTL curto. - Busca por
qem nome/cidade usa índicepg_trgmGIN (ou restringe a prefix/telefone) — deixa de ser seq scan por página. O lookup de municípios acessíveis é memoizado por request— absorvido pelo cache por contexto degetAccessibleMunicipalityIds(hardening 2026-07-23); conferir compartilhamento lista+overview no PR.chunk/requireTable/INSERT_CHUNK_SIZE/ scaffold de tipos drizzle vivem num helper compartilhado, reusado porsupporterImportBulk.tseelectionResultsImport.ts(import TSE).- O scaffold de tipos drizzle não apaga a tipagem de colunas: assertion runtime dos nomes de coluna em
payload.db.tables.supporterao init ouPostgresTransactionDatabaseexpõe uminserttipado. Variante de— entregue comogetAccessibleNucleusIdssemPayloadRequestgetAdvisorMunicipalityIds(src/utilities/access/municipalities.ts).Todos os— entregue (F4 completa; ver Fase 4).formActions.tsrestantes migram para os helpers compartilhados- Guardrails:
overrideAccess: falsecomuser; escritas multi-step continuam emwithPayloadTransaction; locks advisory continuam xact-level; sem novoConsent; sem mudança de comportamento visível.
Decisões travadas
- Um item C8, fases ordenadas. Mesmo racional do C6/C7: um ID de roadmap, PRs por fase. Ordem: perf do caminho quente (import) → perf de leitura (lista) → DRY de helpers → DRY de forms.
- Dependência dura de C6 (merge). O código que C8 otimiza é o código do C6; não reabre o escopo de v1 do
supporter/supporterImportBatch. - Cortável se a base nominal permanecer pequena. Fases 1–2 (perf de import/lista) só rendem com base real em volume; se a base ficar pequena até 16/08, adiar para Janela 3. Fases 3–4 (DRY) são baratas e reduzem drift — manter se houver folga.
- Sem migration de schema em Fases 1–3. Fase 2 pode exigir uma migration pequena (
pg_trgmGIN emcontact.name/contact.city) — única migration do item. postgresTransactionLocks.tsé pré-existente e compartilhado com o path single-create (upsertContactByPhone,enforceUniqueContactPhone). A Fase 1 troca o loop de locks por uma query única sem mudar o contratoacquireContactPhoneLocks(payload, req, phones[])— callers não mudam.- i18n e naming (AGENTS.md): identificadores em inglês (
acquireContactPhoneLocksmantido,drizzleBulk.ts,getAdvisorMunicipalityIds, etc.), strings visíveis em pt-BR.
Questões em aberto
pg_trgmvs prefix-only para busca de nome?ILIKE '%q%'precisa de trigram para ser sargable; prefix-only (ILIKE 'q%') usa btree mas perde busca infix. Recomendação:pg_trgmGIN (a busca infix é o comportamento esperado pela UI hoje); migration pequenaadd_contact_trgm_index.- Overview
totalderivado dototalDocsda lista vs cache? Derivar elimina um COUNT mas acopla overview ao carregamento da lista (overview some se lista não carrega). Recomendação: derivartotalderesult.totalDocs(já computado pela lista) e rodar só os doisFILTERno overview; cache só se houver reclamação de latency. - Assertion runtime de colunas vs widen
PostgresTransactionDatabase? Assertion é barata e pega o bug na inicialização; widen é mais limpo mas tocapostgresTransactionLocks.ts(pré-existente). Recomendação: assertion runtime emsupporterImportBulk.ts(escopo do item); widen fica como follow-up de plataforma. - Migrar formActions de planos aqui (C8) ou em C7? O plano do C6 anotou "mapper de form actions de planos ficou para C7". Recomendação: consolidar TODOS os formActions restantes em C8 Fase 4 (C8 é o "DRY pós-C6" e C6 introduziu os helpers); C7 Fase 3 continua dono dos helpers FormData/URL de território do actionPlan. Atualizar a nota cruzada no plano do C6 na hora do PR.
Abordagem proposta
flowchart TD
F1["Fase 1 — Perf import ✓<br/>locks em 1 RT · .returning · drizzle reads"]
F2["Fase 2 — Perf lista ✓<br/>totalDocs · trgm · advisorMunicipalityIds"]
F3["Fase 3 — DRY helpers<br/>drizzleBulk ✓ · getAdvisorMunicipalityIds ✓ · DrizzleTx assertion ✓"]
F4["Fase 4 — DRY forms ✓<br/>(entregue 2026-07-21 + 2026-07-23)"]
C6["C6 apoiadores ✓"] --> F1
F1 --> F2
F2 --> F3
F3 --> F4
Fase 1 — Perf do caminho quente de import — ENTREGUE (2026-07-19)
As-built:
acquireContactPhoneLocks→acquireTextAdvisoryLocksemsrc/utilities/postgresTransactionLocks.ts: uma querySELECT pg_advisory_xact_lock(hashtextextended(k, 0)) FROM unnest(ARRAY[...])por batch (chaves deduplicadas e ordenadas). Contrato inalterado; pinado emtests/int/postgresTransactionLocks.int.spec.ts.bulkInsertSupporterImport(src/utilities/supporter/supporterImportBulk.ts):.returning()para IDs de contacts novos; leituras de existência via SQL drizzle (phone IN (...)/contact_id IN (...) AND municipality_id IS NULL) — zeropayload.findna txn.assertDrizzleColumnsno init.- Import CSV end-to-end:
tests/int/campaignSupporter.int.spec.ts(preview→confirm, token HMAC).
Fase 2 — Perf de leitura da lista — ENTREGUE (2026-07-19)
As-built:
loadSupportersPageData(src/utilities/supporter/supporterPageData.ts): overview receberesult.totalDocs; aggregate roda só os doisCOUNT(*) FILTER(certo+tende, indeciso). Retornanullquandototal <= 0.computeSupporterListOverviewAggregate(src/utilities/supporter/supporterListOverviewAggregate.ts):advisorMunicipalityIdspré-carregados uma vez por render (advisor); filtros unificados viasupporterListFilters.ts/supporterListSqlFilters.ts(entregue no C9).- Migration
20260719_020000_add_contact_trgm_index: extensãopg_trgm+ GIN emcontact.nameecontact.city. contactSearchQuery:isContactSearchQueryReady(mín. 2 chars) embuildSupporterSearchTerms— lista e overview ignoramqde 1 caractere. Pinado emtests/int/supporterListFilters.int.spec.tsecampaignSupporter.int.spec.ts.
Fase 3 — DRY de helpers
src/utilities/drizzleBulk.ts(novo): extrairchunk,requireTable,INSERT_CHUNK_SIZE, e o scaffold de tiposDrizzleTx/PayloadDbdesupporterImportBulk.tseelectionResultsImport.ts(hoje byte-idênticos). Importar de ambos.Variante de— entregue no split de access (hardening 2026-07-23):getAccessibleNucleusIdssemPayloadRequestgetAdvisorMunicipalityIds(src/utilities/access/municipalities.ts) é a implementação canônica do lookupadvisors contains user.idsemPayloadRequest, reusada porgetAccessibleMunicipalityIds(cache por contexto) e pelo aggregate.CampaignListEmptyState(novo,src/components/campaign/): o blocoEmpty*comSearchXIcon+ "Ajuste a busca ou os filtros…" + CTA de limpar está copiado em sete superfícies —apoiadores,liderancas,demandas,organizacoes,dobradinhas,planoseMunicipalityList(que em B16, 2026-07-25, ganhou a melhor versão do CTA:CampaignTransitionAnchorpreservandosort/dir, hoje ilhada numa das sete). Extrair({ title, description, action })e adotar pelo menos no par idêntico (apoiadores + municípios) antes de converter o resto. (fonte: revisão de reuso pós-B16, 2026-07-25)firstValuelocal em quatro parsers:campaignDemandData.ts,organizationData.ts,leadershipData.tsemunicipalityUpdatePageData.tsredeclaram byte a byte ofirstValuejá exportado porcampaignListUrl.ts— trocar pelos imports (migrar essas listas pararesolveListUrl/buildListHrefcontinua fora deste plano). (mesma fonte)- Type safety do bulk: assertion runtime em
supporterImportBulk.tsqueObject.keys(payload.db.tables.supporter)contém as colunas esperadas (contact,municipality,consent,createdBy, …) no init do módulo — pega o bugcontactIdvscontactantes do runtime. (Widen dePostgresTransactionDatabasefica como follow-up de plataforma.)
Fase 4 — DRY de forms — ENTREGUE (2026-07-21 + hardening 2026-07-23)
Migrar os— completo:formActions.tsrestantes paramapCampaignFormActionError+campaignFormFields- Twin lista↔
/editar:municipalityStaffFormActions.ts(entregue 2026-07-21 comoplazaStaffFormActions.tsempracas/; renomeado na M1);editar/formActions.tsmantém sóupdateMunicipalityStrategyFormAction. - 4 ladders
*/nova(demandas, dobradinhas, lideranças, organizações): migrados pararunCampaignRedirectFormActionno hardening 2026-07-23 (Fase 5). - Todos os demais
formActions(convite/[token],apoiadores/*,planos/*,municipios/[slug]/*,demandas/[slug],dobradinhas/[slug],liderancas/[id],organizacoes/[slug]) já usammapCampaignFormActionError;planos/[slug]/lifecycleFormActions.tsretorna só safe-messages fixas (sem ladder — nada a migrar).
- Twin lista↔
Migrar—fieldError/firstErrorlocal em forms legadosCampaignInviteForm.tsxjá importa decampaignFormFields.ts.- Nota pós-remodel: paths
nucleos/*/pracas/*foram superseded; não reabrir.
Fase 4b — o guard do F4 só vê formActions.ts — ENTREGUE (2026-07-27)
Appetite: ~2h eng, fill-in. O /simplify do C13 mostrou que a invariante do F4 é menor do que o texto acima sugere: o guard campaign formActions convention (tests/unit/codebaseConventions.unit.spec.ts) filtrava file.endsWith('/formActions.ts'), então os arquivos de action irmãos que cada rota escreveu depois — lifecycleFormActions.ts, resultFormActions.ts, updateFormActions.ts, taskActions.ts — não eram verificados em nenhuma rota. A exceção documentada na Revisão 2026-07-24 ("lifecycleFormActions.ts só retorna safe-messages fixas — sem ladder para migrar") foi escrita quando o alvo era mapCampaignFormActionError; com runCampaignRedirectFormAction (Pass 2 W4d) a conta mudou, porque esse arquivo faz exatamente a forma que o wrapper padroniza: dois catch nus que descartam o erro + redirect no sucesso.
As-built:
- Guard ampliado para basename
/^\w*[Ff]ormActions\.ts$/+taskActions.ts. - Migrados para
runCampaignRedirectFormAction/runCampaignFormAction:atividades/[slug]/lifecycleFormActions.ts,resultFormActions.ts,updateFormActions.ts;municipios/[slug]/updateFormActions.ts,pledgeFormActions.ts;municipios/municipalityStaffFormActions.ts(4 actions). - Allowlist com racional:
taskActions.ts(contrato posicional);atividades/formActions.ts(unique-violation + duplicate-title);apoiadores/[id]/formActions.ts(message-only flatten). - Guard exige call site
runCampaign…FormAction(— comentário nomeando o wrapper não passa. - Docstring de
runCampaignFormActionprecisada: as exceções hand-rolled são só os dois allowlisted acima. /simplifypós-F4b: pre-parse deidemlifecycleFormActionssó engoleFormDataBoundaryError.
Já resolvido no simplify (não reabrir): loophole de comment-token no guard; catch nu do lifecycle id.
Explicitamente fora / Adiado com gatilho (triage 2026-07-27):
actions/password.ts/profile.ts/leaderSupporter.tsexportam*FormActioncom ladder hand-rolled fora do filtro*FormActions.ts— gatilho: próximo toque nesses arquivos ou decisão de expandir o guard além de route-level FormActions.convite/[token]/formActions.tslogin half ainda hand-rolled (password-confirm + redirect) — gatilho: próximo toque no convite; autofill já usa o wrapper.
Revisão 2026-07-27: F4b fechada; F1–F2 seguiam com gatilho de volume — fechadas 2026-08-01 (Issue #37; código já em main desde 2026-07-19).
Migration: Fases 1, 3, 4, 4b sem schema. Fase 2: pnpm migrate:create add_contact_trgm_index (extensão + 2 GIN indexes). Sem Consent novo.
Dependências
- Dura: C6 Escala e DRY pós-C2 (código em
supporterImportBulk.ts/supporterListOverviewAggregate.ts/supporterPageData.ts/campaignFormActionError.ts). - Reusa:
src/utilities/contactPhoneInvariant.ts,postgresTransactionLocks.ts,getPostgresTransactionDatabase,relationshipId,src/utilities/access/municipalities.ts(getAccessibleMunicipalityIds,getAdvisorMunicipalityIds),electionResultsImport.ts(paradrizzleBulk.ts), C6 plano escala-dry-pos-c2.md.
Não escopo
- Reabrir decisões de v1 do C6 (token HMAC vs opaque,
supporterImportBatchcomo staging,skipContactPhoneInvariantfail-closed) — escala-dry-pos-c2.md. - Helpers FormData/URL de território do actionPlan — C7 Fase 3 / escala-dry-pos-c3.md.
- Camada de zonas no mapa, coroplético — B3/B4.
- Widen de
PostgresTransactionDatabasepara exporinserttipado — follow-up de plataforma (a assertion runtime da Fase 3 cobre o bug). - Notificações / push — D2.
Referências
docs/roadmap.md— Trilha C, item C8; sequência Janela 2- escala-dry-pos-c2.md — C6 entregue; precedente do formato
- escala-dry-pos-b2.md — precedente B5 (pós-B2 simplify)
- escala-dry-pos-c3.md — precedente C7 (pós-C3 simplify)
- Review
/simplify2026-07-19 (quality / reuse / performance) — origem das fases src/utilities/supporter/supporterImportBulk.ts— bulk insert (locks, .returning, drizzle reads)src/utilities/postgresTransactionLocks.ts—acquireTextAdvisoryLocks(batch advisory locks)src/utilities/supporter/supporterListOverviewAggregate.ts— aggregate SQL +resolveAccessConstraintsrc/utilities/supporter/supporterPageData.ts—loadSupportersPageData(overview derivado detotalDocs)src/utilities/electionResultsImport.ts—chunk/requireTable/INSERT_CHUNK_SIZEduplicadossrc/utilities/access/municipalities.ts—getAccessibleMunicipalityIds/getAdvisorMunicipalityIdssrc/utilities/campaignFormActionError.ts/campaignFormFields.ts— helpers adotados por 2 forms- AGENTS.md —
overrideAccess: false, transações, locks advisory xact-level, naming
Simplify (2026-07-19)
Duas passagens /simplify sobre o diff do C8 (pré- e pós-rebase em main com C7) aplicaram limpezas pontuais sem mudar comportamento: safeMessages opcional em mapCampaignFormActionError, drizzleResultRows reutilizado em campaignInviteRepository, chunk de IN nas leituras drizzle do bulk import, select: { id: true } via DynamicFind em getCoordinatorNucleusIds, prefetch de núcleos do coordenador para o overview, errorProps no CampaignInviteForm/NucleusUpdateForm, e hoisting de erros de campo. Validado com tsc --noEmit, pnpm lint, pnpm test:int, pnpm build e scan Aikido.
Os débitos que os revisores marcaram como importantes e maiores que cleanup foram registrados como item C9 — escala-dry-pos-c8.md:
- Filtros duplicados —
buildSupporterListWhere(Payload) vsbuildAggregateSql(SQL cru); risco de drift entre KPIs e lista. contactSearchQuerynão wireado no aggregate — overview ainda disparaILIKEparaqde 1 caractere apesar dopg_trgme do guard de C7.- Lookup de núcleos do coordenador repetido — até 3× por navegação em
/campanha/apoiadores(list access + options + overview prefetch). apoiadores/[id]/formActions.tsfora do mapper — contratomessagevsfieldErrorsexige desenho antes de migrar.- Componentes de form com
fieldErrorlocal —NucleusForm,ActionPlanForm,NucleusTerritoryFields,VoteEstimateDialog, etc.
Explicitamente fora (tradeoffs aceitos no C8): waterfall lista→overview (precisa de totalDocs); unificar lista+aggregate numa query; widen de PostgresTransactionDatabase.
Simplify (2026-07-21 pós-B9)
— entregue 2026-07-21 (C8 F4): /simplify da entrega B9 marcou como maior que cleanup o twin listFormActions.ts ↔ editar/formActions.tsmunicipalityStaffFormActions.ts (à época plazaStaffFormActions.ts) unifica os 3 wrappers compartilhados; editar/formActions.ts mantém só updateMunicipalityStrategyFormAction.
Explicitamente fora (triage capture-review-debts 2026-07-21 pós-B9): hook useMunicipalityListPopoverForm (gatilho: 4º inline editor); MunicipalityAdvisorCheckboxList (gatilho: 3º uso ou refactor do form /editar); layout responsivo único mobile+desktop na lista (gatilho: page size >25 ou profiling de hydration); lazy-load de getEligibleAdvisorOptions (staff set pequeno); PopoverAnchor export não usado (scaffold shadcn); helper → entregue como parsePoliticalTrendStatusparsePoliticalTrendStatusFormValue no prep A9+; unit tests dos MunicipalityList*Control (int access cobre domínio); E2E save inline (gatilho: smoke pós-merge B9).
Simplify (2026-07-21 pós-A9+)
Débito restante na Fase 4: unificar os wrappers de server action duplicados — entregue 2026-07-21 (C8 F4): ver seção Simplify pós-B9.
Simplify (2026-07-21 pós-C8 F4)
Passagem /simplify + capture-review-debts após fill-in C8 F4 (twin praça→município DRY).
Já resolvido (não reabrir): municipalityStaffFormActions.ts (à época plazaStaffFormActions); municipalityStaffEditSafeMessages (à época plazaStaffEditSafeMessages); tipo MunicipalityStaffFormAction em MunicipalityList.tsx; import direto em editar/page.tsx (Next.js não permite re-export em 'use server'); hygiene de refs em planos-pai A9/B9/estimativa.
Explicitamente fora (triage capture-review-debts 2026-07-21 pós-C8 F4):
- Runner genérico
runCampaignFormAction— gatilho: 5+ wrappers idênticos ou 4º cluster de forms na mesma superfície (hoje ~18 arquivos com try/catch inline). revalidateMunicipalityListPathscomscopepor tipo de save — gatilho: N saves inline + profiling de rerender inaceitável (ver escala-dry-pos-a9.md Adiado com gatilho).pledgeFormActions/updateFormActions→revalidateMunicipalityListPaths— gatilho: forms ganhammunicipalitySlugou unificação de revalidate no detalhe do município.reloadCampaignActorpor submit emactions/municipality.ts— gatilho: profiling mostra latência dominante em saves staff inline.- Fundir
updateMunicipalityStrategyFormActionemmunicipalityStaffFormActions.ts— parse/safeMessagesdistintos; superfície só/editar.