ops(OPS46): e2e local coleta via hook ESM (next/cache + server-only) e allocator de município com registry anti-wrap (Closes #733)

Fase 1: playwright bare (--list, extensão VS Code, runs ad-hoc) quebrava na
coleta — next@15 sem exports não resolve 'next/cache' em ESM nativo e
server-only exige a condição react-server; o script test:e2e carregava ambas
via NODE_OPTIONS (por isso o CI passava). Hook de resolução ESM vitest-free
(e2eEsmResolve.mjs) registrada no main via module.register + propagada aos
workers via NODE_OPTIONS quando invocada bare; path do script intocado.

Fase 2: a sequência persistente campaign_fixture_municipality_alloc nunca é
resetada e um ciclo completo claima ~505 do catálogo de 435 — o wrap em todo
ciclo em DB de worktree fazia dois runs concorrentes receberem o mesmo índice
e purgarem as linhas vivas um do outro. Registry de claims compartilhado
vitest-free (campaignMunicipalityAllocator.ts): INSERT ON CONFLICT DO NOTHING
com skip de slot vivo, steal de claim stale (TTL 2h), release no cleanup dos
dois fixtures, erro claro de exaustão. Guard estático no
testMunicipalityAllocatorConventions + spec int de stress.
This commit is contained in:
Francisco Solla 2026-08-23 13:51:09 -03:00
parent 1cc32a3d09
commit 89bf9d0854
11 changed files with 536 additions and 36 deletions

View file

@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**E1 — extração do hook compartilhado de busca assíncrona com debounce (2026-08-23):** a 3ª cópia do efeito de busca async (requestId + `setTimeout(250)` + loading/failed) — introduzida pela C143 em `DemandResponsibleMultiSelect` (demand), e presente em `ResponsibleMultiSelect` (C90) e `AsyncSearchCombobox` — vira o hook `useAsyncSearchOptions({ open, query, search, isQueryReady? }) → { options, loading, failed }` (`src/hooks/useAsyncSearchOptions.ts`, novo; constante `ASYNC_SEARCH_DEBOUNCE_MS` exportada). Os 3 componentes consomem o hook e mantêm só o que é seu (grupos por typeLabel, avatares, headings de sugestões do município, `pinnedOptions`, filtro de selecionados, mensagens de vazio); semântica de query vazia preservada por consumidor (Demand busca sempre → sugestões; ResponsibleMultiSelect reseta via `isContactSearchQueryReady`; combobox com gate configurável — o efeito sync `selected←value` fica no componente). Fix pontual: server actions intocadas (continuam props — hook client-safe por injeção), sem schema/migration/access/Consent. Testes: spec unit novo `tests/unit/useAsyncSearchOptions.unit.spec.ts` (renderHook + fake timers, 9 casos: debounce/trim, coalesce, gate reset, closed, failed + retry, in-flight loading, out-of-order, quirk gate→not-ready com resposta stale pinado — comportamento herdado dos 3 originais, registrado na decisão D3 do impl plan). Impl plan `docs/plans/extrair-hook-busca-assincrona-debounce-impl.md`. Verificação: unit 2428 + int 815 verdes, e2e activity/demand (campaignActivity, campaignRegisterDemand, campaignDemandVisibility) verdes em re-run (1 flake de cold-compile pré-existente isolado), tsc/lint/format/cycles/build verdes; knip com o erro de load pré-existente (exit 0); fora de escopo: `WizardMunicipalitySearchStep` (4ª variante, AbortController — defer com gatilho no plano).
**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`.
**C142 — interface do /campanha respeita o perfil de permissão do assessor (2026-08-21):** a entrega fechada parte do C141 (que definiu os eixos `visibility`/`editing` no `campaignUser` e os gates server-side). Com o C142, a UI de todos os rascunhos staff do `/campanha` obedece de ponta a ponta o perfil de edição do assessor: com `editing = somente_leitura` não há nenhum controle de escrita em lugar nenhum (listas, detalhes, tabs, wizards); com `editing = tudo` o assessor enxerga/edita tudo o que vê; com `carteira` mantém o comportamento por carteira. Discriminação das entregas: (1) núcleo puro `src/lib/campaignAdvisorProfile.ts` com `advisorEditingScope()`/`rowEditingAllowed()` (retorna `none` para combinação incoerente `editing=tudo` + `visibility≠tudo`) e `gate: 'writable'` em `requireCampaignPageActor` (redireciona para `/campanha`); (2) todas as células compartilhadas (CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor/Card/WorkflowCard, MunicipalityV2\*Cells, MunicipalityGoalAccountCard, ActivityTaskChecklist, SuggestionCard, MunicipalityPledgesPanel, etc.) ganharam o prop `readOnly`; (3) páginas staff — municípios (lista desktop+mobile, detalhe com tabs Overview/Updates/Leaderships/Suggestions, edição), pessoas (invite e criação gated), lideranças, dobradinhas, demandas, atividades, apoiadores, organizações — escondem os botões de criação (`Nova demanda`, `Nova atividade`, `Planejar giro`, `Novo`/`Importar CSV`, `Nova organização`, `Nova liderança`, `Nova dobradinha`) com `editingScope !== 'none'`; (4) home: `homeActionsForRole(role, editingScope)` filtra os 4 wizards para `somente_leitura` (sobra só o atalho de navegação); (5) `shouldMountQuickActionsFab` não monta o FAB para `somente_leitura`; (6) registry de quick actions (`resolveQuickActionsForPath`) recebe `editingScope` e filtra; (7) e2e novo `tests/e2e/campaignPermissionProfile.e2e.spec.ts` cobre os 3 perfis (somente_leitura sem controles, carteira com carteira, tudo com tudo) usando `createCampaignUser` com `visibility`/`editing`. Verificação: tsc/lint/format/knip/cycles/units (2419) + `gate:fast` verdes; PR em main com auto-merge (rebase); nada muda para coordinator/candidate/leader.
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).

View file

@ -0,0 +1 @@
**OPS46 — infra de teste e2e: coleta local consertada (next/cache) + allocator de município sem colisão no wrap (2026-08-23):** entrega de dois débitos do OPS45 (capture-review-debts). **Fase 1 — e2e local não coleta:** `pnpm exec playwright test --list` falhava em todo spec com `Cannot find module 'node_modules/next/cache'` (e o diagnóstico correto do CI-vs-máquina: não há delta — o CI só roda pelo script `pnpm test:e2e`, que carrega `--conditions=react-server --import=tsx/esm` no `NODE_OPTIONS`; comando bare não tinha nada). Causa: `next@15` não declara `exports` → ESM nativo não resolve `next/cache` (o arquivo é `cache.js`), e `server-only` precisa da condição `react-server`. Fix no runner, zero mudança em produção: hook de resolução ESM vitest-free `tests/helpers/e2eEsmResolve.mjs` (mapeia `next/cache`→`next/cache.js` e injeta `react-server` nas conditions) registrada no processo main via `module.register` e propagada aos workers via `NODE_OPTIONS=--import=e2eEsmLoader.mjs` no `playwright.config.ts` — só quando os flags do script já não estão presentes. Aceite: bare `playwright test --list` coleta 198 testes em 41 arquivos (antes: 0); runs reais bare admin 5/5 e campaign 18/18; `pnpm test:e2e` intocado. **Fase 2 — allocator de município com wrap destrutivo:** a sequência persistente `campaign_fixture_municipality_alloc` nunca é resetada e um ciclo completo de suíte claima ~505 do catálogo de 435 (medido: 432 no int + 73 no e2e) → o wrap acontece em todo ciclo em DB persistente de worktree, e dois runs concorrentes podiam receber o mesmo índice e purgar as linhas vivas um do outro (purge-on-claim). Fix: registry de claims em módulo compartilhado vitest-free `tests/helpers/campaignMunicipalityAllocator.ts` — tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id`, `claimed_at`), claim via `INSERT ON CONFLICT DO NOTHING RETURNING` (conflito = slot vivo → pula; claim stale >2h de run crashado → rouba; tudo ocupado → erro claro de exaustão), release no `cleanup()` dos dois fixtures (int + e2e, só quando claimaram). Guard estático novo no `testMunicipalityAllocatorConventions` (proíbe `nextval`/sequence fora do módulo) + spec int de stress `campaignMunicipalityAllocator.int.spec.ts` (exclusividade viva por 3 wraps, exaustão+recuperação, steal, release). Verificação: tsc/lint/format/cycles verdes (knip: limitação P3 pré-existente de não carregar payload.config, igual no HEAD), unit 2420 + int 819 (92 arquivos) verdes com tabela de claims zerada ao final, e2e local afetado (admin 5/5 + campaign 18/18 bare) verdes; importMap.js restaurado com envs `S3_*` dummy após o `--list` bare regenerá-lo sem elas (classe OPS69 — o guard do #87 cobre no CI). Impl plan: `docs/plans/ops46-infra-teste-e2e-local-allocator-impl.md`.

View file

@ -0,0 +1,133 @@
# OPS46 — Infra de teste e2e: e2e local não coleta (next/cache) + allocator de município com wrap
Status: implementado
Atualizado em: 2026-08-23
Issue: #733
Impl plan do plano de intenção: `docs/plans/ops46-infra-teste-e2e-local-allocator.md`
## Fase 1 — e2e local não coleta (`Cannot find module 'next/cache'`)
### Causa raiz (medida)
- `next@15.4.11` não declara `exports` no package.json → ESM nativo do Node
**nunca** resolve o subpath `next/cache` (o arquivo existe como `cache.js`,
mas ESM não tenta extensões — `ERR_MODULE_NOT_FOUND` com "Did you mean to
import next/cache.js?").
- `server-only` (0.0.1) resolve `index.js` (que lança) sem a condição de
export `react-server` (`--conditions=react-server`).
- `pnpm test:e2e` carrega ambas as correções via
`NODE_OPTIONS="--no-deprecation --conditions=react-server --import=tsx/esm"`
— por isso o CI passa: o CI nunca roda `playwright` bare, sempre via script.
Qualquer invocação bare (extensão VS Code, `pnpm exec playwright test
--list`, runs ad-hoc) quebra na coleta.
- CI vs máquina: sem delta — o CI usa o mesmo comando com os mesmos flags;
a hipótese do plano ("investigar o que difere no ambiente CI") se resolveu
em "nada difere — o CI só roda pelo script".
### Abordagem implementada (aprovada no GATE)
Fix no nível do runner, zero mudança em código de produção:
1. `tests/helpers/e2eEsmResolve.mjs` — hook de resolução ESM com 2 rewrites:
- `next/cache` → `next/cache.js` (entry oficial, TS mapeia para `cache.d.ts`);
- injeta `react-server` nas `conditions` de resolução (equivalente exato do
flag `--conditions=react-server`, aplicado em runtime via `nextResolve`).
2. `tests/helpers/e2eEsmLoader.mjs` — entry `--import` que registra a hook
(o padrão do tsx: `--import` só funciona se o módulo chamar `module.register`).
3. `playwright.config.ts` — quando `NODE_OPTIONS` **não** tem
`--conditions=react-server` (invocação bare): `register()` a hook no
processo main (a coleta do `--list` acontece aqui, pós-load do config) e
propaga `--import=<loader>` via `NODE_OPTIONS` para os workers (herdam o
env no spawn). Com os flags presentes (script), nada muda.
### Opções rejeitadas
- `next/cache` → `next/cache.js` na fonte (~40 arquivos): toca código de
produção para um problema de infra de teste e não resolve `server-only`
(o comando bare continuaria quebrando); não idiomático.
- Patch do `next` via pnpm `patchedDependencies` adicionando `exports`:
exports parcial quebra qualquer subpath não listado; frágil por versão.
- `--import`/flags no script apenas + documentar: não satisfaz o aceite
(`pnpm exec playwright test --list` coleta na máquina do humano).
### Verificação Fase 1
- `pnpm exec playwright test --list` (bare, sem flags): **198 testes em 41
arquivos** (antes: `Total: 0 tests in 0 files`).
- Run real bare `--project=admin`: **5 passed** (workers com a hook).
- Run real bare `--project=campaign` `campaignHomeActions`: **18 passed**.
- `pnpm test:e2e --list` (script, flags): inalterado, 198.
## Fase 2 — allocator de município com wrap destrutivo
### Medição (plano: "medir primeiro")
- Claims por ciclo completo num DB persistente: **~505** (432
`getMunicipality()` no int + 73 `claimMunicipality()` no e2e) > 435 do
catálogo → o wrap acontece em **todo** ciclo de suíte completa. Bug agudo
(não teórico): após o wrap, dois runs concorrentes podem receber o mesmo
índice e o purge-on-claim de cada um apaga as linhas vivas do outro.
### Abordagem implementada
Registry de claims em módulo compartilhado vitest-free
`tests/helpers/campaignMunicipalityAllocator.ts` (mesma convenção do
`campaignResidue.ts` — importável pelo processo Playwright do e2e):
- Tabela `campaign_fixture_municipality_claims` (`index` PK, `run_id` text,
`claimed_at` timestamptz default now()) criada lazy com a sequence
(`CREATE IF NOT EXISTS`, corrida 23505 tolerada).
- `claimMunicipalityIndex(payload, catalogSize, runID)`: `nextval` → índice →
`INSERT ON CONFLICT ("index") DO NOTHING RETURNING`; conflito = run vivo
segura o slot → pula; claim **stale** (TTL 2h, run crashado) → rouba e
retenta; tudo ocupado por claims vivos → erro claro ("exhausted") em vez
de colisão silenciosa.
- `releaseMunicipalityClaims(payload, runID)`: `DELETE WHERE run_id` — chamado
no `cleanup()` de ambos os fixtures (int `CampaignFixtures.cleanup`,
e2e `CampaignE2EOwnership.cleanup`), só quando o fixture claimou
(Set `claimedMunicipalityIndexes` — cleanup sem claims não toca a tabela).
- O fixture int perdeu o allocator inline (`nextAllocatedMunicipalityIndex`
+ a sequence própria); o e2e idem (CREATE SEQUENCE + nextval inline).
### Guards e testes
- Guard estático novo no `testMunicipalityAllocatorConventions.unit.spec.ts`:
proíbe `nextval(`/`campaign_fixture_municipality_alloc` em `tests/int`,
`tests/e2e` e `tests/helpers` fora do módulo do allocator — todo claim passa
pelo registry.
- Spec int comportamental `tests/int/campaignMunicipalityAllocator.int.spec.ts`
(catalogSize sintético, robusto a concorrência da suíte paralela):
1. exclusividade viva através de múltiplos wraps (âncora segura 1 slot por
120 claims de runs efêmeros > 3 wraps do espaço de 40);
2. exaustão (erro claro) + recuperação após release;
3. steal de claim stale (envelhecida 3h);
4. release libera slots para reuso.
### Verificação Fase 2
- Spec do allocator: 4/4 verdes, estável em re-runs.
- Suíte int completa: **92 arquivos / 819 testes, todos verdes** (o fixture
refatorado claima ~432 municípios sob 8 workers paralelos).
- Tabela de claims **zerada** ao final da suíte (release funcionando).
- e2e campaign (`campaignHomeActions`, wizard com claims reais): 18/18, claims
zeradas após o run.
## Riscos e notas
- A hook depende de `module.register` (estável desde Node 20.6); só ativa em
invocações bare — o path do script (`pnpm test:e2e`) fica intocado e é o
fallback se algo mudar.
- TTL 2h > duração de qualquer run vivo; claim crashado bloqueia slots até o
TTL (erro de exaustão menciona o prazo).
- Um run vivo não pode claimar mais de `catalogSize` slots (a própria
exclusividade exaure) — correto por design; fixtures reais liberam por
teste.
- Durante a entrega, o `--list` bare regenerou o `importMap.js` sem envs
`S3_*` (classe OPS69/OPS70 #87, já documentada) — restaurado com
`generate:importmap` + envs dummy.
## Aceite
- [x] Fase 1: `pnpm exec playwright test --list` coleta na máquina do humano
- [x] Fase 2: sem colisão de claim após N wraps (stress test com wraps reais)
- [x] Guards: `testMunicipalityAllocatorConventions` verde após a mudança

View file

@ -1,7 +1,35 @@
import { register } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, devices } from '@playwright/test'
import { config as loadEnv } from 'dotenv'
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
/*
* OPS46 — a bare `playwright test` (VS Code extension, `pnpm exec playwright
* test --list`, ad-hoc runs) must collect like `pnpm test:e2e` does. That
* script carries the needed flags in NODE_OPTIONS (`--conditions=react-server
* --import=tsx/esm`); this block re-applies the same behavior when they are
* absent:
* - register() installs the resolution hook (tests/helpers/e2eEsmResolve.mjs)
* in THIS process — test collection happens here after config load, which
* is why a bare `--list` needs it in-process;
* - the NODE_OPTIONS mutation hands the same hook to the worker processes
* Playwright spawns for actual runs (they inherit the env at spawn).
* When the flags are already present (the scripted path) nothing changes.
*/
const bareInvocation = !process.env.NODE_OPTIONS?.includes('--conditions=react-server')
if (bareInvocation) {
register(new URL('./tests/helpers/e2eEsmResolve.mjs', import.meta.url))
const configDir = path.dirname(fileURLToPath(import.meta.url))
const loaderEntry = path.join(configDir, 'tests', 'helpers', 'e2eEsmLoader.mjs')
process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, `--import=${loaderEntry}`]
.filter(Boolean)
.join(' ')
}
import {
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
GOOGLE_CALENDAR_TEST_KEY,

View file

@ -9,6 +9,10 @@ import type { CampaignUser } from '../../../src/payload-types.js'
import config from '../../../src/payload.config.js'
import { withPayloadTransaction } from '../../../src/utilities/payloadTransaction.js'
import { assertTestDatabase } from '../../helpers/assertTestDatabase.js'
import {
claimMunicipalityIndex,
releaseMunicipalityClaims,
} from '../../helpers/campaignMunicipalityAllocator.js'
import { purgeMunicipalityResidue } from '../../helpers/campaignResidue.js'
import { withInviteConsent } from '../../helpers/testDatabaseLease.js'
import { test as base, expect } from './e2eTest.js'
@ -75,6 +79,7 @@ export class CampaignE2EOwnership {
readonly payload: Payload
readonly runID = randomUUID()
private counter = 0
private readonly claimedMunicipalityIndexes = new Set<number>()
private readonly touchedMunicipalities = new Set<number>()
private readonly owned = new Map<OwnedCollection, Set<number>>(
deletionOrder.map((collection) => [collection, new Set<number>()]),
@ -145,17 +150,12 @@ export class CampaignE2EOwnership {
}
async claimMunicipality(): Promise<{ id: number; name: string; slug: string }> {
await this.rootPayload.db.drizzle
.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "campaign_fixture_municipality_alloc"`))
.catch((error: unknown) => {
if ((error as { code?: string }).code === '23505') return
throw error
})
const result = await this.rootPayload.db.drizzle.execute(
sql.raw(`SELECT nextval('"campaign_fixture_municipality_alloc"') AS "value"`),
const index = await claimMunicipalityIndex(
this.rootPayload,
municipalityCatalog.length,
this.runID,
)
const index =
Number((result.rows[0] as { value: string | number }).value) % municipalityCatalog.length
this.claimedMunicipalityIndexes.add(index)
const slug = municipalityCatalog[index]!.slug
const found = await this.rootPayload.find({
collection: 'municipality',
@ -423,6 +423,9 @@ export class CampaignE2EOwnership {
}
async cleanup(): Promise<void> {
if (this.claimedMunicipalityIndexes.size > 0) {
await releaseMunicipalityClaims(this.rootPayload, this.runID)
}
await this.discoverOwnedRows()
await withPayloadTransaction(this.rootPayload, async ({ req, transactionID }) => {
const transaction = this.rootPayload.db.sessions?.[String(transactionID)]?.db as

View file

@ -21,6 +21,7 @@ import type {
VotePledge,
} from '@/payload-types'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { claimMunicipalityIndex, releaseMunicipalityClaims } from './campaignMunicipalityAllocator'
import { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue'
/**
@ -202,32 +203,11 @@ const combineErrors = (primary: unknown, cleanup: unknown): AggregateError =>
/**
* Cross-process municipality allocator: seeded municipalities are shared reference rows, so
* concurrently running spec files must never operate on the same municipality. A
* Postgres sequence hands out globally unique catalog indexes.
* concurrently running spec files must never operate on the same municipality. The allocator
* (`campaignMunicipalityAllocator.ts`) hands out a catalog index per claim — unique across
* live runs even after the Postgres sequence wraps (see its module docs for the wrap
* mechanism). Claims are released by `cleanup()` so later runs reuse the slots.
*/
const MUNICIPALITY_ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc'
let allocationSequenceReady: Promise<void> | undefined
const nextAllocatedMunicipalityIndex = async (
payload: Payload,
catalogSize: number,
): Promise<number> => {
allocationSequenceReady ??= payload.db.drizzle
.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${MUNICIPALITY_ALLOCATION_SEQUENCE}"`))
.then(() => undefined)
.catch((error: unknown) => {
// IF NOT EXISTS still races across parallel workers (pg_class unique
// violation, SQLSTATE 23505) — the sequence exists, which is all we need.
if ((error as { code?: string }).code === '23505') return undefined
throw error
})
await allocationSequenceReady
const result = await payload.db.drizzle.execute(
sql.raw(`SELECT nextval('"${MUNICIPALITY_ALLOCATION_SEQUENCE}"') AS "value"`),
)
const value = Number((result.rows[0] as { value: string | number }).value)
return value % catalogSize
}
export class CampaignFixtures {
readonly payload: Payload
@ -236,6 +216,7 @@ export class CampaignFixtures {
private cleaned = false
private counter = 0
private municipalityCursor = 0
private readonly claimedMunicipalityIndexes = new Set<number>()
private readonly markers = new Set<string>()
private readonly owned = emptyOwnedIDs()
private readonly touchedMunicipalities = new Set<number>()
@ -377,10 +358,12 @@ export class CampaignFixtures {
async getMunicipality(slug?: string): Promise<Municipality> {
let requestedSlug = slug
if (!requestedSlug) {
const index = await nextAllocatedMunicipalityIndex(
const index = await claimMunicipalityIndex(
this.rootPayload,
municipalityCatalog.length,
this.runID,
)
this.claimedMunicipalityIndexes.add(index)
requestedSlug = municipalityCatalog[index]!.slug
this.municipalityCursor += 1
}
@ -1033,6 +1016,9 @@ export class CampaignFixtures {
async cleanup(): Promise<void> {
if (this.cleaned) return
if (this.claimedMunicipalityIndexes.size > 0) {
await releaseMunicipalityClaims(this.rootPayload, this.runID)
}
await this.discoverDependents()
await withPayloadTransaction(this.rootPayload, async ({ req }) => {
for (const collection of [

View file

@ -0,0 +1,128 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
/**
* Cross-process municipality allocator — shared by the int
* (`campaignFixtures.ts`) and e2e (`campaignE2EFixtures.ts`) suites.
*
* Deliberately VITEST-FREE: the e2e fixture imports this module inside the
* Playwright process, and a vitest import there crashes at load time (same
* rule as `campaignResidue.ts` — OPS45).
*
* The Postgres sequence hands out globally unique catalog indexes across
* parallel workers, but it is NEVER reset: worktree test databases are
* persistent, and a full int + e2e cycle claims ~505 of the 435 catalog
* slots, so the residue space wraps on every cycle. Once it wraps, two
* concurrent runs can receive the SAME index — and the purge-on-claim
* contract turns that collision into corruption (each run deletes the other
* run's live rows). The claims registry below keeps the sequence as the
* cursor but makes a claim exclusive per LIVE run:
*
* - claim: nextval → index → INSERT (index, run_id) ON CONFLICT DO NOTHING;
* a conflict means a live run holds the slot, so skip to the next value;
* - stale steal: a claim older than STALE_CLAIM_WINDOW belongs to a
* CRASHED run (its cleanup never ran), so it is deleted and the slot is
* reclaimed — without this, one aborted run would block slots for hours;
* - release: the fixture deletes its own rows at cleanup, so the slots
* become claimable again for later runs;
* - exhaustion: all slots held by fresh claims → clear error instead of a
* silent collision.
*/
const ALLOCATION_SEQUENCE = 'campaign_fixture_municipality_alloc'
const ALLOCATION_CLAIMS = 'campaign_fixture_municipality_claims'
const STALE_CLAIM_WINDOW = "interval '2 hours'"
// Identifiers in the `sql` templates below are INLINE LITERALS on purpose:
// `${}` in a drizzle template is a bound parameter, never an identifier.
const claimsTable = `"${ALLOCATION_CLAIMS}"`
let allocatorReady: Promise<void> | undefined
const ensureAllocatorSchema = (payload: Payload): Promise<void> => {
allocatorReady ??= Promise.all([
payload.db.drizzle.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${ALLOCATION_SEQUENCE}"`)),
payload.db.drizzle.execute(
sql.raw(`
CREATE TABLE IF NOT EXISTS ${claimsTable} (
"index" integer PRIMARY KEY,
"run_id" text NOT NULL,
"claimed_at" timestamptz NOT NULL DEFAULT now()
)
`),
),
])
.then(() => undefined)
.catch((error: unknown) => {
// IF NOT EXISTS still races across parallel workers (pg_class unique
// violation, SQLSTATE 23505) — the objects exist, which is all we need.
if ((error as { code?: string }).code === '23505') return undefined
throw error
})
return allocatorReady
}
const nextAllocationValue = async (payload: Payload): Promise<number> => {
const result = await payload.db.drizzle.execute(
sql.raw(`SELECT nextval('"${ALLOCATION_SEQUENCE}"') AS "value"`),
)
return Number((result.rows[0] as { value: string | number }).value)
}
const tryClaimIndex = async (payload: Payload, index: number, runID: string): Promise<boolean> => {
const result = await payload.db.drizzle.execute(sql`
INSERT INTO ${sql.raw(claimsTable)} ("index", "run_id")
VALUES (${index}, ${runID})
ON CONFLICT ("index") DO NOTHING
RETURNING "index"
`)
return result.rows.length > 0
}
const tryStealStaleClaim = async (payload: Payload, index: number): Promise<boolean> => {
const result = await payload.db.drizzle.execute(sql`
DELETE FROM ${sql.raw(claimsTable)}
WHERE "index" = ${index} AND "claimed_at" < now() - ${sql.raw(STALE_CLAIM_WINDOW)}
RETURNING "index"
`)
return result.rows.length > 0
}
/**
* A catalog index that no LIVE run holds. Never returns an index that a
* concurrently running fixture still owns, even after the sequence wraps —
* the claim only lands when the INSERT wins. One run may hold at most
* `catalogSize` slots at once (its own claims are live); claiming past that
* with the same runID exhausts, which is correct — real fixtures release at
* each test's cleanup.
*/
export const claimMunicipalityIndex = async (
payload: Payload,
catalogSize: number,
runID: string,
): Promise<number> => {
await ensureAllocatorSchema(payload)
for (let attempt = 0; attempt < catalogSize; attempt += 1) {
const index = (await nextAllocationValue(payload)) % catalogSize
if (await tryClaimIndex(payload, index, runID)) return index
if (await tryStealStaleClaim(payload, index)) {
if (await tryClaimIndex(payload, index, runID)) return index
}
}
throw new Error(
`Municipality allocator exhausted: all ${catalogSize} catalog slots are held by ` +
`live test runs. Stale claims from crashed runs expire after 2 hours.`,
)
}
/**
* Frees every slot a run claimed (runID is per fixture instance — one test).
* Called from the fixture cleanup; the claim is held from claim until the
* test's rows are purged, so releasing here cannot collide with the live
* owner (there is none by definition).
*/
export const releaseMunicipalityClaims = async (payload: Payload, runID: string): Promise<void> => {
await payload.db.drizzle.execute(sql`
DELETE FROM ${sql.raw(claimsTable)}
WHERE "run_id" = ${runID}
`)
}

View file

@ -0,0 +1,9 @@
/**
* OPS46 — entry point that registers the Playwright ESM resolution hook
* (see `e2eEsmResolve.mjs`). Passed to worker processes as
* `NODE_OPTIONS=--import=<absolute path>` so bare `playwright test` runs
* resolve `next/cache` and `server-only` exactly like `pnpm test:e2e` does.
*/
import { register } from 'node:module'
register(new URL('./e2eEsmResolve.mjs', import.meta.url))

View file

@ -0,0 +1,29 @@
/**
* OPS46 — ESM resolution hook for the Playwright runner process.
*
* next@15 ships NO `exports` map in its package.json, so native Node ESM
* cannot resolve the bare `next/cache` subpath (the file exists as
* `cache.js`, but ESM never tries extensions); `server-only` needs the
* `react-server` export condition. `pnpm test:e2e` papers over both with
* NODE_OPTIONS flags (`--conditions=react-server --import=tsx/esm`), but a
* bare `playwright test` (VS Code extension, `--list`, ad-hoc runs) has
* neither. This hook applies the same two resolutions in-process:
* - `next/cache` → `next/cache.js` (the shipped, TS-mapped entry)
* - inject `react-server` into the resolution conditions (same effect as
* the `--conditions=react-server` flag)
*
* Registered in the main process by `playwright.config.ts` and passed to the
* worker processes via `NODE_OPTIONS=--import=...` (see `e2eEsmLoader.mjs`).
*/
export async function resolve(specifier, context, nextResolve) {
if (specifier === 'next/cache') {
return nextResolve('next/cache.js', context)
}
if (!context.conditions.includes('react-server')) {
return nextResolve(specifier, {
...context,
conditions: ['react-server', ...context.conditions],
})
}
return nextResolve(specifier, context)
}

View file

@ -0,0 +1,154 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { sql } from '@payloadcms/db-postgres'
import { getPayload, type Payload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import {
claimMunicipalityIndex,
releaseMunicipalityClaims,
} from '../helpers/campaignMunicipalityAllocator'
/**
* Behavioral stress of the allocator's claims registry (OPS46). The real
* catalog has 435 slots and a full int + e2e cycle claims ~505 of them, so
* the sequence wraps every cycle on persistent worktree databases; these
* tests exercise the wrap with a small synthetic `catalogSize` so the
* properties are observable in one run. They run under the PARALLEL int
* suite, so assertions never depend on the exact index a claim lands on —
* only on the registry invariants (live exclusivity through the wrap,
* exhaustion, release, stale steal) that hold regardless of what other
* specs claim concurrently.
*/
let payload: Payload
const releasedRunIDs: string[] = []
const freshRunID = (): string => {
const runID = randomUUID()
releasedRunIDs.push(runID)
return runID
}
const claimCount = async (runID: string): Promise<number> => {
const result = await payload.db.drizzle.execute(sql`
SELECT count(*)::int AS "count"
FROM "campaign_fixture_municipality_claims"
WHERE "run_id" = ${runID}
`)
return Number((result.rows[0] as { count: number }).count)
}
describe('campaign municipality allocator registry', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
await Promise.all(releasedRunIDs.map((runID) => releaseMunicipalityClaims(payload, runID)))
})
it('never hands a live run an index another live run holds, across many sequence wraps', async () => {
const catalogSize = 40
// The anchor run keeps ONE slot held for the whole test; the residue
// space (40 slots) wraps ~3 times over the 120 claims below, and the
// registry must skip the held slot every time the cursor lands on it.
const anchor = freshRunID()
const anchorIndex = await claimMunicipalityIndex(payload, catalogSize, anchor)
for (let cycle = 0; cycle < 60; cycle += 1) {
const runID = freshRunID()
const first = await claimMunicipalityIndex(payload, catalogSize, runID)
const second = await claimMunicipalityIndex(payload, catalogSize, runID)
expect(second).not.toBe(first)
expect([first, second]).not.toContain(anchorIndex)
await releaseMunicipalityClaims(payload, runID)
}
expect(await claimCount(anchor)).toBe(1)
})
it('fails loudly with an exhaustion error instead of colliding, then recovers on release', async () => {
const catalogSize = 8
const holder = freshRunID()
let exhausted = false
for (let attempt = 0; attempt < 100 && !exhausted; attempt += 1) {
try {
await claimMunicipalityIndex(payload, catalogSize, holder)
} catch (error) {
expect((error as Error).message).toMatch(/exhausted/i)
exhausted = true
}
}
expect(exhausted, 'all slots held must surface as an exhaustion error').toBe(true)
await releaseMunicipalityClaims(payload, holder)
expect(await claimCount(holder)).toBe(0)
const recovered = freshRunID()
let claimed = false
for (let attempt = 0; attempt < 20 && !claimed; attempt += 1) {
try {
await claimMunicipalityIndex(payload, catalogSize, recovered)
claimed = true
} catch (error) {
expect((error as Error).message).toMatch(/exhausted/i)
}
}
expect(claimed, 'released slots must be claimable again').toBe(true)
})
it('steals claims of a crashed run once they age past the stale window', async () => {
const catalogSize = 40
const crashed = freshRunID()
const survivor = freshRunID()
const crashedIndex = await claimMunicipalityIndex(payload, catalogSize, crashed)
// Simulate a run killed before cleanup: age its claim past the 2h window.
await payload.db.drizzle.execute(sql`
UPDATE "campaign_fixture_municipality_claims"
SET "claimed_at" = now() - interval '3 hours'
WHERE "run_id" = ${crashed}
`)
// The stale slot is reclaimable: keep claiming until the survivor lands
// on the crashed run's index (the sequence advances one slot per claim,
// so a bounded loop covers every slot of the residue space).
let reclaimed = false
for (let attempt = 0; attempt < catalogSize * 3 && !reclaimed; attempt += 1) {
if ((await claimMunicipalityIndex(payload, catalogSize, survivor)) === crashedIndex) {
reclaimed = true
}
}
expect(reclaimed, 'stale claim must be stealable').toBe(true)
expect(await claimCount(crashed)).toBe(0)
// The survivor may hold up to catalogSize slots at this point; release
// inline so the later tests keep free space (the afterAll is the net).
await releaseMunicipalityClaims(payload, survivor)
})
it('releases every slot of a run on release and lets later runs reuse the space', async () => {
const catalogSize = 40
const runA = freshRunID()
const runB = freshRunID()
const claimedA = new Set<number>()
for (let i = 0; i < 5; i += 1) {
claimedA.add(await claimMunicipalityIndex(payload, catalogSize, runA))
}
expect(claimedA.size).toBe(5)
expect(await claimCount(runA)).toBe(5)
await releaseMunicipalityClaims(payload, runA)
expect(await claimCount(runA)).toBe(0)
const claimedB = new Set<number>()
for (let i = 0; i < 5; i += 1) {
claimedB.add(await claimMunicipalityIndex(payload, catalogSize, runB))
}
expect(claimedB.size).toBe(5)
})
})

View file

@ -95,4 +95,31 @@ describe('municipality allocator discipline (miss #73)', () => {
'anchor with ^ (or locate by href) — catalog names are not prefix-unique',
).toEqual([])
})
it('routes every claim through the shared allocator module (OPS46)', () => {
// The claims registry (campaignMunicipalityAllocator.ts) is what makes the
// purge-on-claim contract safe after the sequence wraps; a direct
// nextval/sequence reference anywhere else reintroduces the collision it
// exists to prevent.
const offenders: string[] = []
const allocatorModule = repoPath(
resolve(repoRoot, 'tests/helpers/campaignMunicipalityAllocator.ts'),
)
const scanned = [
...specFiles,
...walkFiles(resolve(repoRoot, 'tests/helpers'), ['.ts']),
].filter((file) => repoPath(file) !== allocatorModule)
for (const file of scanned) {
const source = readFileSync(file, 'utf8')
if (/nextval\(|campaign_fixture_municipality_alloc/.test(source)) {
offenders.push(repoPath(file))
}
}
expect(
offenders,
'claim through claimMunicipalityIndex — direct sequence access bypasses the claims registry',
).toEqual([])
})
})