mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(worktree): ambientes isolados por worktree — porta própria, bancos próprios, migrations no provision
pnpm worktree next [--go] [--no-migrate] agora provisiona um ambiente determinístico por worktree: slot = parte numérica do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos), dev server em 3100+slot, bancos teqo_wt<slot> / teqo_wt<slot>_test no container compartilhado (-p teqo) com a cadeia de migrations aplicada; escreve .env.local + .env.test.local (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD_SECRET/Blob/VAPID do main. kill remove o worktree e dropa os bancos gerados (WITH FORCE, best-effort). Suites leem .env.test.local depois de .env.test (vitest.setup / playwright / db-doctor / gate-ci espelham o layering) e assertTestDatabase aceita teqo_test ou teqo_<worktree>_test em localhost só (fail-closed preservado). db:start/db:stop fixam -p teqo (um container de qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura em scripts/lib/worktree-env.mjs (unit-tested); fluxo validado end-to-end: provision → migrate → 598/598 int no banco do worktree → kill sem resíduo.
This commit is contained in:
parent
4736533723
commit
8125334401
16 changed files with 532 additions and 41 deletions
|
|
@ -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<slot>`, test database `teqo_wt<slot>_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<slot>` (dev) and `teqo_wt<slot>_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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>-<slug>`**: `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/<branch>`.
|
||||
- **`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<slot>` / `teqo_wt<slot>_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:<porta>` (`pnpm dev`), banco `teqo_wt<slot>`.
|
||||
- **test**: banco `teqo_wt<slot>_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).
|
||||
|
|
|
|||
|
|
@ -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 <name>` → 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<slot>` (dev) and `teqo_wt<slot>_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_<worktree>_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<slot>` / `teqo_wt<slot>_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 <symbol>`) before deleting.
|
||||
|
|
|
|||
|
|
@ -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<slot>` / `teqo_wt<slot>_test`), so agents never fight over port 3000 or the shared `teqo_test`. See `.agents/skills/local-database`.
|
||||
|
||||
### Content & cache revalidation
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<slot>`/`teqo_wt<slot>_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*<worktree>\_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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -19,11 +19,17 @@ import { diagnoseDatabaseTarget } from './db-doctor.mjs'
|
|||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const readTestEnv = () => {
|
||||
// `.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 {
|
||||
return parseEnv(readFileSync(path.join(repoRoot, '.env.test'), 'utf8'))
|
||||
Object.assign(layered, parseEnv(readFileSync(path.join(repoRoot, name), 'utf8')))
|
||||
} catch {
|
||||
return {}
|
||||
// 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',
|
||||
}
|
||||
|
|
|
|||
67
scripts/lib/worktree-env.mjs
Normal file
67
scripts/lib/worktree-env.mjs
Normal file
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
* pnpm worktree next [--go] [--no-migrate]
|
||||
* cria worktree a partir de origin/main para a
|
||||
* próxima Issue claimável; branch `<code>-<slug>`.
|
||||
* Com `--go`, imprime `cd <dir>` 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 <nome>')
|
||||
}
|
||||
}
|
||||
|
||||
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 <code>-<slug>);')
|
||||
console.log(' com --go imprime `cd <dir>` 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 <code>-<slug>) e provisiona o')
|
||||
console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
|
||||
console.log(' com --go imprime `cd <dir>` 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())
|
||||
|
|
|
|||
|
|
@ -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_<worktree>_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_<worktree>_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).',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue