diff --git a/.agents/skills/local-database/SKILL.md b/.agents/skills/local-database/SKILL.md index 763491b8..d0d5971c 100644 --- a/.agents/skills/local-database/SKILL.md +++ b/.agents/skills/local-database/SKILL.md @@ -22,6 +22,18 @@ VMs Cloud **não rodam** `docker compose` / `pnpm db:start`. Postgres nativo: `. - Dev database: `postgresql://teqo:teqo@localhost:5432/teqo` - Test database: `postgresql://teqo:teqo@localhost:5432/teqo_test` - Local Postgres major version is pinned to **17** in `docker-compose.yml` to match production Neon. +- **In a worktree provisioned by `pnpm worktree next`**, read `.env.local` / `.env.test.local` instead: dev database `teqo_wt`, test database `teqo_wt_test`, dev server on port `3100+slot` (see "Per-worktree environments" below). + +## Per-worktree environments (`pnpm worktree next`) + +Parallel agents must not fight over port 3000, the shared `teqo_test`, or a second Postgres container. `pnpm worktree next [--go]` provisions each worktree with a deterministic isolated env: + +- `slot` = numeric part of the issue code (`C15` → 15, `B164` → 164; hash fallback without digits; bumped on collision among live worktrees). +- Dev server port `3100+slot`; databases `teqo_wt` (dev) and `teqo_wt_test` (tests), created on the **one shared container** with the full migration chain applied (one-time, ~30 s each). +- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` to regenerate. +- `--no-migrate` skips the migration runs; `pnpm worktree kill` drops the worktree's generated databases (best-effort, `DROP DATABASE … WITH (FORCE)`). +- Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning and no port override. +- Pure derivation: `scripts/lib/worktree-env.mjs` (unit-tested in `tests/unit/worktree.unit.spec.ts`). ## First-time setup diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md index e474b685..cff6bda2 100644 --- a/.agents/skills/worktree-next-issue/SKILL.md +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -19,8 +19,8 @@ Prepara o ambiente git para a próxima Issue da fila de claim, **sem** tocá-la A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui. ```bash -pnpm worktree next # cria worktree de origin/main para a próxima claimável -pnpm worktree kill [--force] # destrói o worktree em que o shell atual está +pnpm worktree next [--go] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente +pnpm worktree kill [--force] # destrói o worktree atual + remove seus bancos gerados ``` No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script. @@ -38,9 +38,20 @@ No terminal interativo, para `--go` trocar de diretório de verdade, use a funç - **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`). - **Branch `-`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`. - **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/`. -- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch. +- **Provisiona o ambiente isolado do worktree** (determinístico, ver "Ambiente provisionado"): dev server na porta `3100+slot` e bancos próprios `teqo_wt` / `teqo_wt_test` no container compartilhado (`-p teqo`), com migrations aplicadas — agentes em paralelo não disputam porta 3000 nem o `teqo_test`. `--no-migrate` pula as migrations (bancos criados, envs escritos). +- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch; remove os bancos gerados do worktree (best-effort). - **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**. +## Ambiente provisionado (leia a saída do script) + +Cada worktree gerado ganha um ambiente próprio — reporte ao agente/humano os valores que o script imprime ao final: + +- **dev**: `http://localhost:` (`pnpm dev`), banco `teqo_wt`. +- **test**: banco `teqo_wt_test` (as suites leem `.env.test.local`, que sobrepõe o `.env.test` commitado; `pnpm test:int` / `test:e2e` / `gate:push` rodam isolados). +- Sem Docker (Cursor Cloud): o script degrada para o compartilhado `teqo`/`teqo_test` com aviso — comportamento antigo. +- Envs geradas têm o marcador `# teqo worktree env (generated by pnpm worktree next)`; **`.env.local`/`.env.test.local` manuais nunca são sobrescritos** (o script pula o provisionamento com aviso). +- Detalhes e derivarão pura: `scripts/lib/worktree-env.mjs`; skill `local-database`. + ## NÃO faz - Não roda `pnpm agent:claim` (mudaria labels no GitHub). diff --git a/AGENTS.md b/AGENTS.md index 7f4f769a..06418c46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,8 @@ These are product/architecture constraints — not a ban on editing existing cod - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). -- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. +- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. +- **Per-worktree environments (`pnpm worktree next`, 2026-08-08):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--go]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain applied; writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning. - To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below. - To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below. @@ -154,7 +155,7 @@ The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTal ### Local verification and deploy checklist -1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds. +1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt` / `teqo_wt_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names. 2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`. 3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. - Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w `) before deleting. diff --git a/README.md b/README.md index 894a667f..6186fb6c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ Teqo then becomes a configurable base product for other politicians in Brazil: 6. Start the dev server: `pnpm dev` 7. Open: `http://localhost:3000` -Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull` (refresh local content from prod), `pnpm db:seed:posts` (import news posts/tags fresh from the live jorgesolla.com.br site into the local db; idempotent by slug, refuses a non-local database), `pnpm db:seed:tse` (import TSE 2014/2018/2022 Bahia election results into the local db; idempotent per year/office/turn scope, refuses a non-local database), `pnpm build:election-aggregates` (regenerate the committed TSE aggregate artifact in `src/lib/electionAggregates/` from locally seeded data). +Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres — always the same shared container via `-p teqo`, from any directory), `pnpm db:pull` (refresh local content from prod), `pnpm db:seed:posts` (import news posts/tags fresh from the live jorgesolla.com.br site into the local db; idempotent by slug, refuses a non-local database), `pnpm db:seed:tse` (import TSE 2014/2018/2022 Bahia election results into the local db; idempotent per year/office/turn scope, refuses a non-local database), `pnpm build:election-aggregates` (regenerate the committed TSE aggregate artifact in `src/lib/electionAggregates/` from locally seeded data). + +Parallel worktrees get isolated environments: `pnpm worktree next` derives a per-branch dev port (`3100+slot`) and its own databases (`teqo_wt` / `teqo_wt_test`), so agents never fight over port 3000 or the shared `teqo_test`. See `.agents/skills/local-database`. ### Content & cache revalidation diff --git a/docker-compose.yml b/docker-compose.yml index b469ebb2..a83ef4fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,11 @@ services: - '5432:5432' # Default max_connections=100 is tight for vitest forks (≈CPUs × pg pool) # plus a leftover `next start` from local repro. Raise the ceiling so the - # int gate fails on real assertions, not "too many clients already". - command: ['postgres', '-c', 'max_connections=200'] + # int gate fails on real assertions, not "too many clients already". 400 + # leaves headroom for several agents' int suites on the SAME container in + # parallel (each worktree provisions its own databases — see + # scripts/worktree.mjs — but they share this one Postgres on 5432). + command: ['postgres', '-c', 'max_connections=400'] volumes: - pgdata:/var/lib/postgresql/data # Runs only on first initialization of the volume. Creates the dedicated diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 139bb9cf..fdf86331 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt`/`teqo_wt_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested). + **Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md). **Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md). diff --git a/package.json b/package.json index cb075434..5eb1917f 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "type": "module", "scripts": { "build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build", - "db:start": "docker compose up -d postgres", - "db:stop": "docker compose down", + "db:start": "docker compose -p teqo up -d --wait postgres", + "db:stop": "docker compose -p teqo down", "db:doctor": "node scripts/db-doctor.mjs", "db:pull": "node scripts/db-pull.mjs", "db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 91fe8301..1b17c023 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,6 +9,9 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase' * `override: true` guarantees the test DB wins over anything in the shell. */ loadEnv({ path: '.env.test', override: true }) +// Per-worktree isolation: `.env.test.local` (gitignored, written by +// `pnpm worktree next`) wins over the committed `.env.test` when present. +loadEnv({ path: '.env.test.local', override: true }) // Optional local override when the default Docker port is occupied (e.g. mapped // to 5433) — same escape hatch vitest.setup.ts documents. diff --git a/scripts/db-doctor.mjs b/scripts/db-doctor.mjs index 1ccd7b3e..6d582d11 100644 --- a/scripts/db-doctor.mjs +++ b/scripts/db-doctor.mjs @@ -243,13 +243,24 @@ const readEnvFile = (path) => { } } +/** + * Test target exactly as the suites resolve it: `.env.test` then + * `.env.test.local` (per-worktree, gitignored, wins), then the + * TEQO_TEST_DATABASE_URL escape hatch beats DATABASE_URL (same order as + * vitest.setup.ts / playwright.config.ts). + */ +const readTestTargetEnv = () => { + const layered = { ...readEnvFile('.env.test'), ...readEnvFile('.env.test.local') } + return layered.TEQO_TEST_DATABASE_URL ?? layered.DATABASE_URL +} + const main = async () => { // Next.js precedence for the dev target, without mutating process.env. const devUrl = process.env.DATABASE_URL ?? readEnvFile('.env.local').DATABASE_URL ?? readEnvFile('.env').DATABASE_URL - const testUrl = readEnvFile('.env.test').DATABASE_URL + const testUrl = readTestTargetEnv() let healthy = warnOnSharedDataVolumes() if (devUrl) { diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index f670d8cd..356d3cd5 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -19,11 +19,17 @@ import { diagnoseDatabaseTarget } from './db-doctor.mjs' const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const readTestEnv = () => { - try { - return parseEnv(readFileSync(path.join(repoRoot, '.env.test'), 'utf8')) - } catch { - return {} + // `.env.test.local` (per-worktree, gitignored, written by `pnpm worktree next`) + // wins over the committed `.env.test`, mirroring vitest.setup.ts. + const layered = {} + for (const name of ['.env.test', '.env.test.local']) { + try { + Object.assign(layered, parseEnv(readFileSync(path.join(repoRoot, name), 'utf8'))) + } catch { + // file missing — fine + } } + return layered } const testEnv = readTestEnv() @@ -31,7 +37,10 @@ const testEnv = readTestEnv() /** Env for migrate / seed / build — matches ci-pr.yml service Postgres. */ const dbEnv = { ...process.env, - DATABASE_URL: testEnv.DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo_test', + DATABASE_URL: + testEnv.TEQO_TEST_DATABASE_URL ?? + testEnv.DATABASE_URL ?? + 'postgresql://teqo:teqo@localhost:5432/teqo_test', PAYLOAD_SECRET: testEnv.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production', NEXT_PUBLIC_SITE_URL: testEnv.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000', } diff --git a/scripts/lib/worktree-env.mjs b/scripts/lib/worktree-env.mjs new file mode 100644 index 00000000..9a3b3bf6 --- /dev/null +++ b/scripts/lib/worktree-env.mjs @@ -0,0 +1,67 @@ +/** + * Pure helpers for the per-worktree environment provisioning in + * `scripts/worktree.mjs` — deterministic port + database names derived from + * the branch, so parallel agents (each in their own worktree) never fight + * over port 3000, the shared `teqo_test` database, or a second Postgres + * container on 5432. + * + * Contract: `slot` is the single collision domain. Port and BOTH database + * names derive from the final slot, so a bumped slot frees all three at + * once. Deterministic for a given (branch, code, live taken-slots set) — + * the same "depends on live state" determinism the claim-queue branch picker + * already has. + */ + +/** Dev-server ports start here: slot 15 → http://localhost:3115. */ +export const DEV_PORT_BASE = 3100 + +/** Marker line identifying a generated env file (never overwrite manual ones). */ +export const GENERATED_ENV_MARKER = '# teqo worktree env (generated by pnpm worktree next)' + +/** Slot cap — ports above 3100 + 999 would leave the usual ephemeral range. */ +const MAX_SLOT = 999 + +/** First run of digits in the issue code (`C15` → 15, `B164` → 164); null when none. */ +export const numericSlotOfCode = (code) => { + const match = /([0-9]+)/.exec(code ?? '') + return match ? Number(match[1]) : null +} + +/** Stable 32-bit FNV-1a hash → slot in [0, MAX_SLOT] for codes without digits. */ +export const hashSlotOf = (value) => { + let hash = 0x811c9dc5 + for (const byte of Buffer.from(String(value), 'utf8')) { + hash ^= byte + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash % (MAX_SLOT + 1) +} + +/** The slot a branch prefers before collision resolution. */ +const preferredSlotForBranch = (branch, code) => { + const numeric = numericSlotOfCode(code) + return numeric !== null && numeric <= MAX_SLOT ? numeric : hashSlotOf(branch) +} + +export const devPortForSlot = (slot) => DEV_PORT_BASE + slot +export const devDatabaseForSlot = (slot) => `teqo_wt${slot}` +export const testDatabaseForSlot = (slot) => `teqo_wt${slot}_test` + +/** DB name charset guard — derived names are fed into `CREATE DATABASE` SQL. */ +export const isGeneratedDatabaseName = (name) => /^teqo_wt[0-9]+(_test)?$/.test(name ?? '') + +/** + * Final environment for a branch. `takenSlots` = slots already claimed by + * OTHER live worktrees; bumps +1 until free (bounded — live slot sets are + * tiny). Every value a consumer needs derives from the one final slot. + */ +export const worktreeEnvironment = ({ branch, code, takenSlots = new Set() }) => { + let slot = preferredSlotForBranch(branch, code) + while (takenSlots.has(slot)) slot += 1 + return { + slot, + devPort: devPortForSlot(slot), + devDatabase: devDatabaseForSlot(slot), + testDatabase: testDatabaseForSlot(slot), + } +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 3ecf1899..986009fc 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -2,30 +2,55 @@ * `pnpm worktree` — worktree management determinístico em torno da fila de * claim do projeto (mesma fila de `agent:claim` / agent pool). * - * pnpm worktree next [--go] cria worktree a partir de origin/main para a - * próxima Issue claimável; branch `-`. - * Com `--go`, imprime `cd ` no fim — node - * não muda o cwd do shell pai; o shell chamador - * aplica (opencode/CDP usa o opencode command; - * terminal interativo: função `worktree()` em - * `.agents/shell/worktree.sh`, sourced no profile). + * pnpm worktree next [--go] [--no-migrate] + * cria worktree a partir de origin/main para a + * próxima Issue claimável; branch `-`. + * Com `--go`, imprime `cd ` no fim — node + * não muda o cwd do shell pai; o shell chamador + * aplica (opencode/CDP usa o opencode command; + * terminal interativo: função `worktree()` em + * `.agents/shell/worktree.sh`, sourced no profile). + * Também PROVISIONA o ambiente isolado do worktree: + * porta do dev server + bancos próprios derivados + * deterministicamente do branch (ver + * scripts/lib/worktree-env.mjs), para agentes em + * paralelo não disputarem porta 3000 nem o + * `teqo_test` compartilhado. `--no-migrate` pula a + * aplicação das migrations nos bancos novos. * pnpm worktree kill [--force] destrói o worktree em que o shell atual está - * (recusa worktree sujo sem `--force`) + * (recusa worktree sujo sem `--force`) e remove os + * bancos gerados do worktree (best-effort) * * Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`). * Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor). */ import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' +import { parse as parseEnv } from 'dotenv' +import pg from 'pg' + import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs' +import { + DEV_PORT_BASE, + GENERATED_ENV_MARKER, + isGeneratedDatabaseName, + worktreeEnvironment, +} from './lib/worktree-env.mjs' import { branchNameForIssue } from './lib/worktree.mjs' const die = dieAgent('worktree') const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo') +/** Fixed compose project so `db:start`/provisioning always target ONE container. */ +const SHARED_COMPOSE_PROJECT = 'teqo' +/** Maintenance database on the shared local container — only ever localhost. */ +const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres' + /** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */ const git = (args, { okIfFails = false } = {}) => { try { @@ -56,7 +81,198 @@ const parseWorktreeList = (porcelain) => { return entries } -const cmdNext = (go) => { +/** + * Slots already claimed by OTHER live worktrees, read from their generated + * `.env.local` (only files carrying the marker count — manual envs claim + * nothing and are never overwritten). + */ +const readLiveSlots = (entries, excludedDir) => { + const slots = new Set() + for (const entry of entries) { + if (resolve(entry.path) === resolve(excludedDir)) continue + try { + const text = readFileSync(join(entry.path, '.env.local'), 'utf8') + if (!text.includes(GENERATED_ENV_MARKER)) continue + const match = /^PORT=([0-9]+)/m.exec(text) + const slot = match ? Number(match[1]) - DEV_PORT_BASE : null + if (slot !== null && Number.isInteger(slot) && slot >= 0) slots.add(slot) + } catch { + // worktree dir without a generated env file — nothing claimed + } + } + return slots +} + +/** Merge the main repo's `.env` then `.env.local` (local wins) — secret source. */ +const readMainEnv = (mainRoot) => { + const merged = {} + for (const name of ['.env', '.env.local']) { + try { + Object.assign(merged, parseEnv(readFileSync(join(mainRoot, name), 'utf8'))) + } catch { + // file missing — fine + } + } + return merged +} + +const envFileIsGenerated = (dir, name) => { + try { + return readFileSync(join(dir, name), 'utf8').includes(GENERATED_ENV_MARKER) + } catch { + return false + } +} + +/** Start (or reuse) the ONE shared Postgres container from any worktree dir. */ +const dockerComposeUp = (dir) => { + execFileSync( + 'docker', + ['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d', '--wait', 'postgres'], + { cwd: dir, stdio: 'inherit' }, + ) +} + +/** `CREATE DATABASE` for each missing generated name on the shared container. */ +const ensureDatabases = async (names) => { + const client = new pg.Client({ + connectionString: MAINTENANCE_DATABASE_URL, + connectionTimeoutMillis: 5000, + }) + await client.connect() + try { + for (const name of names) { + if (!isGeneratedDatabaseName(name)) die(`Nome de banco gerado inválido: ${name}`) + const { rows } = await client.query('SELECT 1 AS ok FROM pg_database WHERE datname = $1', [ + name, + ]) + if (rows.length > 0) { + console.log(`[worktree] banco ${name} já existe.`) + continue + } + await client.query(`CREATE DATABASE ${name}`) + console.log(`[worktree] banco criado: ${name}`) + } + } finally { + await client.end().catch(() => {}) + } +} + +const ensureWorktreeDeps = (dir) => { + if (existsSync(join(dir, 'node_modules', '.bin', 'payload'))) return + console.log('[worktree] ▶ node_modules ausente — pnpm install --frozen-lockfile …') + execFileSync('pnpm', ['install', '--frozen-lockfile'], { cwd: dir, stdio: 'inherit' }) +} + +const runMigrate = (dir, databaseUrl, payloadSecret) => { + console.log(`[worktree] ▶ migrations em ${databaseUrl} (uma vez por worktree)…`) + execFileSync('pnpm', ['migrate'], { + cwd: dir, + env: { ...process.env, DATABASE_URL: databaseUrl, PAYLOAD_SECRET: payloadSecret }, + stdio: 'inherit', + }) +} + +/** Cursor Cloud / no-Docker fallback: shared teqo + teqo_test, no port override. */ +const writeFallbackEnv = (dir, branch) => { + const devEnv = [ + GENERATED_ENV_MARKER, + '# fallback sem Docker (Cursor Cloud?): banco compartilhado, sem porta própria.', + `# branch ${branch}`, + 'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo', + '', + ].join('\n') + const testEnv = [ + GENERATED_ENV_MARKER, + '# fallback sem Docker (Cursor Cloud?): banco de teste compartilhado.', + `# branch ${branch}`, + 'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test', + '', + ].join('\n') + writeFileSync(join(dir, '.env.local'), devEnv) + writeFileSync(join(dir, '.env.test.local'), testEnv) +} + +/** + * Deterministic per-worktree environment: shared container + own databases + + * own dev-server port + env files + migrations. Skips everything when a + * manual `.env.local`/`.env.test.local` (no marker) exists — never clobbers. + */ +const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot }) => { + const manual = + (existsSync(join(dir, '.env.local')) && !envFileIsGenerated(dir, '.env.local')) || + (existsSync(join(dir, '.env.test.local')) && !envFileIsGenerated(dir, '.env.test.local')) + if (manual) { + console.warn( + '[worktree] .env.local/.env.test.local manuais presentes — sem provisionar nem sobrescrever.', + ) + console.warn( + '[worktree] Remova os arquivos manuais e rode `pnpm worktree next` de novo para o ambiente isolado.', + ) + return + } + + try { + dockerComposeUp(dir) + } catch (error) { + console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).') + console.warn(`[worktree] (${error.message.split('\n')[0]})`) + writeFallbackEnv(dir, branch) + return + } + + const devDatabase = env.devDatabase + const testDatabase = env.testDatabase + const devUrl = `postgresql://teqo:teqo@localhost:5432/${devDatabase}` + const testUrl = `postgresql://teqo:teqo@localhost:5432/${testDatabase}` + await ensureDatabases([devDatabase, testDatabase]) + + const mainEnv = readMainEnv(mainRoot) + const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex') + const copy = (key) => (mainEnv[key] ? [`${key}=${mainEnv[key]}`] : []) + + const devEnv = [ + GENERATED_ENV_MARKER, + `# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`, + `DATABASE_URL=${devUrl}`, + `PORT=${env.devPort}`, + `NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`, + `PLAYWRIGHT_BASE_URL=http://localhost:${env.devPort}`, + `PAYLOAD_SECRET=${payloadSecret}`, + ...copy('BLOB_READ_WRITE_TOKEN'), + ...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'), + ...copy('VAPID_PUBLIC_KEY'), + ...copy('VAPID_PRIVATE_KEY'), + ...copy('VAPID_SUBJECT'), + ...copy('RESEND_API_KEY'), + ...copy('CAMPAIGN_EMAIL_FROM'), + ...copy('CAMPAIGN_EMAIL_FROM_NAME'), + '', + ].join('\n') + writeFileSync(join(dir, '.env.local'), devEnv) + console.log(`[worktree] .env.local escrito (porta ${env.devPort}).`) + + const testEnv = [ + GENERATED_ENV_MARKER, + `# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`, + '# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.', + `DATABASE_URL=${testUrl}`, + `NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`, + '', + ].join('\n') + writeFileSync(join(dir, '.env.test.local'), testEnv) + console.log('[worktree] .env.test.local escrito.') + + if (skipMigrate) { + console.log('[worktree] --no-migrate: migrations ficam para depois (pnpm migrate).') + return + } + ensureWorktreeDeps(dir) + runMigrate(dir, devUrl, payloadSecret) + runMigrate(dir, testUrl, payloadSecret) +} + +const cmdNext = async (go, skipMigrate) => { const pick = nextClaimableIssue() if (!pick) { die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.') @@ -89,10 +305,64 @@ const cmdNext = (go) => { console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.') } + const mainRoot = entries[0]?.path + const env = worktreeEnvironment({ + branch, + code: pick.meta.id, + takenSlots: readLiveSlots(entries, dir), + }) + await provision({ dir, branch, issue, env, skipMigrate, mainRoot }) + + console.log(`\nAmbiente isolado do worktree (slot ${env.slot}):`) + console.log(` dev server: http://localhost:${env.devPort} (pnpm dev)`) + console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`) + console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`) + if (go) console.log(`cd ${dir}`) } -const cmdKill = (force) => { +/** Generated database names referenced by a worktree's own env files. */ +const worktreeDatabaseNamesOf = (dir) => { + const names = [] + for (const file of ['.env.local', '.env.test.local']) { + try { + const text = readFileSync(join(dir, file), 'utf8') + if (!text.includes(GENERATED_ENV_MARKER)) continue + for (const line of text.split('\n')) { + const match = /^DATABASE_URL=postgresql:\/\/[^/]+\/([a-z0-9_]+)$/.exec(line.trim()) + if (match && isGeneratedDatabaseName(match[1])) names.push(match[1]) + } + } catch { + // no env file — nothing to clean + } + } + return [...new Set(names)] +} + +/** Best-effort DROP DATABASE (WITH FORCE) of the destroyed worktree's DBs. */ +const dropWorktreeDatabases = async (names) => { + if (names.length === 0) return + try { + const client = new pg.Client({ + connectionString: MAINTENANCE_DATABASE_URL, + connectionTimeoutMillis: 5000, + }) + await client.connect() + try { + for (const name of names) { + await client.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`) + console.log(`[worktree] banco removido: ${name}`) + } + } finally { + await client.end().catch(() => {}) + } + } catch (error) { + console.warn(`[worktree] não consegui remover os bancos ${names.join(', ')}: ${error.message}`) + console.warn('[worktree] remova manualmente: docker exec teqo-postgres-1 dropdb -U teqo ') + } +} + +const cmdKill = async (force) => { const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) const mainRoot = entries[0]?.path if (!mainRoot) die('Não consegui ler os worktrees deste repo.') @@ -118,31 +388,39 @@ const cmdKill = (force) => { ) } + // Read the generated DB names BEFORE the directory is removed. + const databaseNames = worktreeDatabaseNamesOf(top) + git(['-C', mainRoot, 'worktree', 'remove', '--force', top]) if (branch) git(['-C', mainRoot, 'branch', '-D', branch]) console.log(`Worktree destruído: ${top}`) console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`) console.log(`Volte ao main: cd ${mainRoot}`) + + await dropWorktreeDatabases(databaseNames) } const { flags, positional } = parseArgs(process.argv.slice(2), new Set()) const subcommand = positional[0] if (!subcommand) { - console.log('Uso: pnpm worktree next [--go] | kill [--force]') - console.log(' next [--go] cria worktree da próxima Issue claimável (branch -);') - console.log(' com --go imprime `cd ` no fim (quem aplica o cd: opencode') - console.log(' command, ou a função `worktree()` de .agents/shell/worktree.sh)') - console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force)') + console.log('Uso: pnpm worktree next [--go] [--no-migrate] | kill [--force]') + console.log(' next [--go] [--no-migrate]') + console.log(' cria worktree da próxima Issue claimável (branch -) e provisiona o') + console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);') + console.log(' com --go imprime `cd ` no fim (quem aplica o cd: opencode command, ou a') + console.log(' função `worktree()` de .agents/shell/worktree.sh); --no-migrate pula migrations') + console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force) e') + console.log(' remove os bancos gerados do worktree (best-effort)') process.exit(1) } try { - if (subcommand === 'next') cmdNext(Boolean(flags.go)) + if (subcommand === 'next') await cmdNext(Boolean(flags.go), Boolean(flags['no-migrate'])) else if (subcommand === 'kill') { if (flags.go) die('`--go` só faz sentido com `next`.') - cmdKill(Boolean(flags.force)) + await cmdKill(Boolean(flags.force)) } else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`) } catch (error) { if (error?.stderr) die(error.stderr.toString().trim()) diff --git a/tests/helpers/assertTestDatabase.ts b/tests/helpers/assertTestDatabase.ts index 1717bc96..cbd83a38 100644 --- a/tests/helpers/assertTestDatabase.ts +++ b/tests/helpers/assertTestDatabase.ts @@ -3,14 +3,20 @@ * * The tests delete and recreate records (see seedUser.ts), so they must NEVER * run against the production database. Rather than trying to detect prod (an - * allowlist we could forget to update), we require the exact local test - * database. A remote database is rejected even if its name looks test-like. + * allowlist we could forget to update), we require a LOCAL database whose name + * is `teqo_test` or `teqo__test` (per-worktree isolation generated + * by `pnpm worktree next`). A remote database is rejected even if its name + * looks test-like. */ + +/** `teqo_test`, `teqo_wt15_test`, … — the _test suffix is the hard contract. */ +const TEST_DATABASE_NAME = /^teqo(_[a-z0-9]+)?_test$/ + export function assertTestDatabase(databaseUrl: string | undefined): void { if (!databaseUrl) { throw new Error( - 'DATABASE_URL is not set. Tests require a dedicated test database configured in .env.test ' + - '(e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).', + 'DATABASE_URL is not set. Tests require a dedicated local test database configured in ' + + '.env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).', ) } @@ -26,13 +32,14 @@ export function assertTestDatabase(databaseUrl: string | undefined): void { if ( parsedUrl.protocol !== 'postgresql:' || - databaseName !== 'teqo_test' || + !TEST_DATABASE_NAME.test(databaseName) || !localHosts.has(parsedUrl.hostname) ) { throw new Error( `Refusing to run tests using protocol "${parsedUrl.protocol || '(unknown)'}" against database ` + `"${databaseName || '(unknown)'}" on host ` + - `"${parsedUrl.hostname || '(unknown)'}". Tests require the exact database "teqo_test" ` + + `"${parsedUrl.hostname || '(unknown)'}". Tests require a local database named ` + + '`teqo_test` or `teqo__test` (per-worktree isolation from `pnpm worktree next`) ' + 'over postgresql: on localhost, 127.0.0.1, or ::1. ' + 'Configure DATABASE_URL in .env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).', ) diff --git a/tests/int/assertTestDatabase.int.spec.ts b/tests/int/assertTestDatabase.int.spec.ts index 3b08f174..7c2ae60b 100644 --- a/tests/int/assertTestDatabase.int.spec.ts +++ b/tests/int/assertTestDatabase.int.spec.ts @@ -5,17 +5,22 @@ import { assertTestDatabase } from '../helpers/assertTestDatabase' describe('assertTestDatabase', () => { it.each([ 'postgresql://teqo:teqo@localhost:5432/teqo_test', + 'postgresql://teqo:teqo@localhost:5432/teqo_wt15_test', 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_test', + 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_wt164_test', 'postgresql://teqo:teqo@[::1]:5432/teqo_test', - ])('accepts the exact local test database at %s', (databaseUrl) => { + ])('accepts the local test database at %s', (databaseUrl) => { expect(() => assertTestDatabase(databaseUrl)).not.toThrow() }) it.each([ 'postgresql://teqo:teqo@localhost:5432/other_test', 'postgresql://teqo:teqo@localhost:5432/teqo', + 'postgresql://teqo:teqo@localhost:5432/teqo_wt15', + 'postgresql://teqo:teqo@localhost:5432/teqo_test_extra', + 'postgresql://teqo:teqo@localhost:5432/teqo_Test_test', 'postgresql://teqo:teqo@db.example.com:5432/teqo_test', - 'postgresql://teqo:teqo@neon.example.com:5432/other_test', + 'postgresql://teqo:teqo@neon.example.com:5432/teqo_wt15_test', 'postgres://teqo:teqo@localhost:5432/teqo_test', 'https://localhost/teqo_test', 'postgresql+srv://teqo:teqo@localhost:5432/teqo_test', diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 0e375897..f11087c5 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from 'vitest' +import { + GENERATED_ENV_MARKER, + devDatabaseForSlot, + devPortForSlot, + hashSlotOf, + isGeneratedDatabaseName, + numericSlotOfCode, + testDatabaseForSlot, + worktreeEnvironment, +} from '../../scripts/lib/worktree-env.mjs' import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs' type TestIssue = { @@ -67,3 +77,70 @@ describe('branchNameForIssue', () => { ) }) }) + +describe('worktreeEnvironment (per-worktree ports and databases)', () => { + it('derives slot, port and database names deterministically from the branch', () => { + const env = worktreeEnvironment({ branch: 'C15-foo', code: 'C15' }) + expect(env).toEqual(worktreeEnvironment({ branch: 'C15-foo', code: 'C15' })) + expect(env).toEqual({ + slot: 15, + devPort: 3115, + devDatabase: 'teqo_wt15', + testDatabase: 'teqo_wt15_test', + }) + }) + + it('uses the numeric part of the issue code as the preferred slot', () => { + expect(numericSlotOfCode('C15')).toBe(15) + expect(numericSlotOfCode('B164')).toBe(164) + expect(numericSlotOfCode('P3')).toBe(3) + expect(numericSlotOfCode('E2')).toBe(2) + }) + + it('falls back to a stable hash slot for codes without digits', () => { + expect(numericSlotOfCode('X')).toBeNull() + const env = worktreeEnvironment({ branch: 'X-foo', code: 'X' }) + expect(env.slot).toBeGreaterThanOrEqual(0) + expect(env.slot).toBeLessThanOrEqual(999) + expect(env).toEqual(worktreeEnvironment({ branch: 'X-foo', code: 'X' })) + expect(hashSlotOf('X-foo')).toBe(hashSlotOf('X-foo')) + }) + + it('bumps to the next free slot when the preferred one is taken', () => { + const env = worktreeEnvironment({ + branch: 'A15-bar', + code: 'A15', + takenSlots: new Set([15, 16]), + }) + expect(env.slot).toBe(17) + expect(env.devPort).toBe(3117) + expect(env.devDatabase).toBe('teqo_wt17') + expect(env.testDatabase).toBe('teqo_wt17_test') + }) + + it('keeps port and database names consistent after a bump', () => { + const env = worktreeEnvironment({ branch: 'C15-foo', code: 'C15', takenSlots: new Set([15]) }) + expect(devPortForSlot(env.slot)).toBe(env.devPort) + expect(devDatabaseForSlot(env.slot)).toBe(env.devDatabase) + expect(testDatabaseForSlot(env.slot)).toBe(env.testDatabase) + }) + + it('caps huge codes into the hash range instead of absurd ports', () => { + const env = worktreeEnvironment({ branch: 'C999999-foo', code: 'C999999' }) + expect(env.slot).toBeLessThanOrEqual(999) + expect(env.devPort).toBeLessThanOrEqual(4099) + }) + + it('accepts only generated names for CREATE/DROP DATABASE', () => { + expect(isGeneratedDatabaseName('teqo_wt15')).toBe(true) + expect(isGeneratedDatabaseName('teqo_wt15_test')).toBe(true) + expect(isGeneratedDatabaseName('teqo')).toBe(false) + expect(isGeneratedDatabaseName('teqo_test')).toBe(false) + expect(isGeneratedDatabaseName('teqo_wt15_extra')).toBe(false) + expect(isGeneratedDatabaseName('teqo_wt15_test; DROP TABLE x')).toBe(false) + }) + + it('pins the marker used to detect generated env files', () => { + expect(GENERATED_ENV_MARKER).toContain('generated by pnpm worktree next') + }) +}) diff --git a/vitest.setup.ts b/vitest.setup.ts index b3a11617..761779bc 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -6,6 +6,9 @@ import { config as loadEnv } from 'dotenv' import { assertTestDatabase } from './tests/helpers/assertTestDatabase' loadEnv({ path: '.env.test', override: true }) +// Per-worktree isolation: `.env.test.local` (gitignored, written by +// `pnpm worktree next`) wins over the committed `.env.test` when present. +loadEnv({ path: '.env.test.local', override: true }) // Optional local override when the default Docker port is occupied (e.g. mapped to 5433). if (process.env.TEQO_TEST_DATABASE_URL) {