diff --git a/.cursor/rules/codebase-map.mdc b/.cursor/rules/codebase-map.mdc index 8ab453e3..491e678b 100644 --- a/.cursor/rules/codebase-map.mdc +++ b/.cursor/rules/codebase-map.mdc @@ -9,7 +9,7 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se ## Direção de dependência -`lib/` (puro, client-safe) → `utilities/` (Payload/Next; loaders com `import 'server-only'`) → `components/` → `app/`. `lib/` nunca importa `utilities/`. Client components importam de módulos server só `import type` — valores compartilhados vivem em módulos de contrato (`municipalityMapContract`, `votePledgeViews`, `lib/campaignRoles`, `lib/campaignConsentKeys`). +`lib/` (puro, client-safe) → `utilities/` (Payload/Next; loaders com `import 'server-only'`) → `components/` → `app/`. `lib/` nunca importa `utilities/` — agora enforcement programático: ESLint `no-restricted-imports` em `src/lib`, `pnpm check:cycles` (madge, 0 ciclos) e `codebaseConventions.unit.spec.ts` (exige `server-only` em utilities acoplados ao Payload). Client components importam de módulos server só `import type` — valores compartilhados vivem em módulos de contrato (`municipalityMapContract`, `votePledgeViews`, `lib/campaignRoles`, `lib/campaignConsentKeys`). ## Onde vive o quê @@ -43,4 +43,4 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se - `leader` é lockdown; view models são por papel; liderança nunca vê `estimatedVotes`. - Consent LGPD resolve por chave estável (`lib/campaignConsentKeys.ts`) e falha fechado. - Copy pt-BR / identificadores em inglês; "Praça"/"Núcleo" não voltam. -- Gate por entrega: `tsc --noEmit`, `pnpm lint` (0 warnings), `pnpm exec knip` (exports em ERROR), `pnpm test`, `pnpm build`. +- Gate por entrega: `tsc --noEmit`, `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip` (exports em ERROR), `pnpm check:cycles` (0 ciclos), `pnpm test`, `pnpm build`. diff --git a/.cursor/rules/engineering-standards.mdc b/.cursor/rules/engineering-standards.mdc index 8d430645..2bb8df18 100644 --- a/.cursor/rules/engineering-standards.mdc +++ b/.cursor/rules/engineering-standards.mdc @@ -11,7 +11,8 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM 1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive. 2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment. -3. **Full gate before handoff:** `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm test` (unit + int, local DB only), `pnpm build`. CI (`.github/workflows/ci.yml`) runs the same on the GitHub mirror. +3. **Full gate before handoff:** `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check` (Prettier + organize-imports is the formatting authority — never hand-align), `pnpm exec knip`, `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int, local DB only), `pnpm build`. CI (`.github/workflows/ci.yml`) runs the same on the GitHub mirror. +4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.cursor/skills/local-database/SKILL.md b/.cursor/skills/local-database/SKILL.md index 87f886ec..b69cedf2 100644 --- a/.cursor/skills/local-database/SKILL.md +++ b/.cursor/skills/local-database/SKILL.md @@ -31,6 +31,12 @@ Production is a live **Neon Postgres** with real citizens' PII. Local developmen Stop the database with `pnpm db:stop`. +## Troubleshooting connectivity (`pnpm db:doctor`) + +When `pnpm dev`, `pnpm build`, or the int suite cannot reach Postgres, run `pnpm db:doctor` before anything else. It checks the dev (`.env.local`/`.env`) and test (`.env.test`) targets with a real connection and prints the concrete remedy: which container to `docker start`, which foreign container is holding the port, or that a container is crash-looping (`docker logs`). `pnpm dev` runs the same preflight through `guard-dev-db`. + +It also flags Postgres containers sharing one data volume. **Never run a second Postgres container against `teqo_pgdata`**: the postmaster lock cannot interlock across container PID namespaces, so both instances write the same WAL and corrupt it (this destroyed the local cluster's checkpoint on 2026-07-25 — recovered via `pg_resetwal` + rebuilding `teqo_test`). The canonical setup is ONE compose container (`teqo-postgres-1`, host port 5432) hosting both `teqo` and `teqo_test`. + ## Refresh local content from prod (`pnpm db:pull`) ```bash diff --git a/.cursor/skills/payload-migrations/SKILL.md b/.cursor/skills/payload-migrations/SKILL.md index 91c98114..3cdf6971 100644 --- a/.cursor/skills/payload-migrations/SKILL.md +++ b/.cursor/skills/payload-migrations/SKILL.md @@ -61,6 +61,14 @@ export async function down({ db }: MigrateDownArgs): Promise { Then add both `up`/`down` and a `name` entry to `src/migrations/index.ts` (order matters — after the previous migration). +### Guard rails for data migrations (learned from D3, 2026-07-25) + +The whatsapp consent-key migration originally targeted `WHERE "key" IS NULL` while the prod-shaped row held `''` — it ran "green" everywhere and tagged nothing. For every hand-written data migration: + +1. **Log affected-row counts.** `UPDATE ... RETURNING` (or `rowCount`) plus a `RAISE NOTICE`/log of the count — the Vercel build log shows it, so a silent no-op is visible at deploy time. An idempotent re-run legitimately reports 0; the first run must not. +2. **Pin it with a spec** exercising the SQL against the realistic data variants (e.g. `NULL` **and** `''`). Precedents: `tests/unit/contactCityMigration.unit.spec.ts`, `tests/int/submitWhatsapp.int.spec.ts`. +3. **Rehearse against prod-shaped data**: `pnpm db:pull`, then apply the migration locally on the pulled copy before deploying — local fixtures and production rows drift, which is exactly how the `NULL` vs `''` miss happened. + ## Verifying against production (read-only, safe) To confirm what a deploy will apply, run status pointed at prod's unpooled URL — this only reads: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e185a6d..40cb664a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,12 +52,18 @@ jobs: - name: Lint (zero warnings) run: pnpm lint + - name: Format check (prettier) + run: pnpm format:check + - name: Typecheck run: pnpm typecheck - name: Dead code (knip) run: pnpm exec knip + - name: Circular dependencies (madge) + run: pnpm check:cycles + - name: Unit tests run: pnpm test:unit diff --git a/.husky/pre-push b/.husky/pre-push index 3c7f364f..0e825e66 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm lint && pnpm typecheck +pnpm lint && pnpm format:check && pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index 544b1c07..fba1dab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si - 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 (`.cursor/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. - 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. @@ -184,7 +185,9 @@ Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Thr 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. 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 exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `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. +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. + - Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there. 4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change. 5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically. 6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent. diff --git a/docs/TESTING.md b/docs/TESTING.md index e151354b..301e3c93 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,7 +4,7 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`): -- **Unit** (`tests/unit/**/*.unit.spec.ts`, 46 specs as of 2026-07-25 (Pass 2)): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database. +- **Unit** (`tests/unit/**/*.unit.spec.ts`, 47 specs as of 2026-07-25 (tooling guards)): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database. - **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned. - **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer. @@ -14,20 +14,21 @@ CI (`.github/workflows/ci.yml`) runs lint (zero warnings), typecheck, knip, unit Modules the 2026-07 hardening phases touch, and where their behavior is pinned: -| Module | Pinned behaviors | Test files | Gaps | -| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| RBAC — `src/utilities/access/*` (split 2026-07-23; `campaignAccess.ts` is the re-export surface) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior | -| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) | -| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope | `municipalityMapData.int.spec.ts` | zone breakdown shape | -| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | | -| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow | -| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | | -| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog | -| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` | -| Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper | -| WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | | -| Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | | -| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) | +| Module | Pinned behaviors | Test files | Gaps | +| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| RBAC — `src/utilities/access/*` (split 2026-07-23; `campaignAccess.ts` is the re-export surface) | per-role municipality/leadership/pledge/supporter/demand/plan scoping; leader lockdown; staff field asymmetry | `campaignMunicipalityAccess`, `campaignVotePledge`, `campaignSupporter`, `campaignUserPhone`, `campaignActionPlan`, `campaignDemandWorkflow`, `collectionAccessLockdown` (int) | no dedicated per-export suite; covered through collection behavior | +| Collection access lockdown | campaign JWT denied on `users`/PII/CMS writes; anonymous read of public content; admin unaffected | `collectionAccessLockdown.int.spec.ts` | REST-transport variant untested (Local API with `overrideAccess: false` is equivalent) | +| `municipalityMapData.ts` | bundle scoping by URL filters + advisor access; `validVotesByYear` incl. 2026 reuse of 2022; null on empty scope | `municipalityMapData.int.spec.ts` | zone breakdown shape | +| `votePledgeData.ts` | `estimated[scenario] ?? declared` aggregation; role scoping; leader denial | `campaignVotePledge.int.spec.ts` | | +| Supporter import (token/bulk) | preview→confirm flow, dedup by phone, consent fail-closed | `campaignSupporter.int.spec.ts` | `supporterImportBulk`/`supporterImportToken` internals only via flow | +| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | | +| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog | +| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` | +| Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper | +| WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | | +| Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | | +| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) | +| Codebase conventions (tooling guards, 2026-07-25) | `server-only` on Payload-coupled utilities; Praça/Núcleo copy ban; eslint legacy-ignore existence; formActions wrapper usage | `codebaseConventions.unit.spec.ts` | boundary rules themselves live in ESLint (`no-restricted-imports`) + madge in CI | ## Characterization Backlog diff --git a/package.json b/package.json index 18bfffc2..f5f84470 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "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: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", "db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs", diff --git a/scripts/db-doctor.mjs b/scripts/db-doctor.mjs new file mode 100644 index 00000000..1ccd7b3e --- /dev/null +++ b/scripts/db-doctor.mjs @@ -0,0 +1,273 @@ +/** + * Diagnoses local database connectivity and prints the actual remedy instead + * of letting Payload fail minutes later with a bare ECONNREFUSED. + * + * Checks both targets: dev (DATABASE_URL from the environment, then + * .env.local, then .env — Next.js precedence) and test (.env.test). For each + * it attempts a real Postgres connection and, on failure, inspects Docker to + * name the culprit: Teqo's container exited, the host port held by another + * project's container, a missing database, or no container at all. + * + * Usage: pnpm db:doctor + * Also imported by scripts/guard-dev-db.mjs as a preflight for `pnpm dev`. + */ + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +import { parse as parseEnv } from 'dotenv' +import pg from 'pg' + +const isTeqoContainer = (name) => name.includes('teqo') + +// Container names come from `docker ps` output, but validate them against +// Docker's own name charset anyway before they reach another exec call — +// defense in depth against argument-shaped names (cannot start with `-`). +const isSafeContainerName = (name) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) + +/** @returns {Array<{name: string, status: string, ports: string, image: string}> | null} */ +const listContainers = () => { + try { + const output = execFileSync( + 'docker', + ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ) + return output + .split('\n') + .filter(Boolean) + .map((line) => { + const [name = '', status = '', ports = '', image = ''] = line.split('\t') + return { name, status, ports, image } + }) + } catch { + return null // docker CLI missing or the daemon is not running + } +} + +/** Host port a container publishes Postgres on, even while exited. */ +const containerHostPort = (name) => { + if (!isSafeContainerName(name)) return null + try { + const output = execFileSync( + 'docker', + [ + 'inspect', + '--format', + '{{(index (index .HostConfig.PortBindings "5432/tcp") 0).HostPort}}', + name, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ) + return output.trim() || null + } catch { + return null + } +} + +const remediesForConnectionRefused = (port, containers) => { + if (!containers) { + return [ + 'Docker looks unreachable — is Docker Desktop running?', + 'Then start the database: pnpm db:start', + ] + } + + const teqoContainers = containers.filter((container) => isTeqoContainer(container.name)) + const remedies = [] + + for (const container of teqoContainers) { + const hostPort = containerHostPort(container.name) + if (container.status.startsWith('Restarting')) { + remedies.push( + `Container "${container.name}" is crash-looping — inspect: docker logs ${container.name}`, + ) + } else if (container.status.startsWith('Up')) { + if (hostPort && hostPort !== String(port)) { + remedies.push( + `Container "${container.name}" is running but publishes host port ${hostPort}, ` + + `while this DATABASE_URL expects ${port} — fix the URL or use the other container.`, + ) + } + } else { + remedies.push( + `Container "${container.name}" exists but is not running` + + (hostPort ? ` (publishes host port ${hostPort})` : '') + + ` — run: docker start ${container.name}`, + ) + } + } + + if (teqoContainers.length === 0) { + remedies.push('No Teqo Postgres container found — run: pnpm db:start') + } else if (remedies.length === 0) { + remedies.push( + `Nothing is listening on port ${port} and no Teqo container explains it — try: pnpm db:start`, + ) + } + + return remedies +} + +const remediesForBadServer = (port, containers, message) => { + const remedies = [`Something IS listening on port ${port}, but the connection failed: ${message}`] + + const holder = containers?.find( + (container) => container.status.startsWith('Up') && container.ports.includes(`:${port}->`), + ) + if (holder && !isTeqoContainer(holder.name)) { + remedies.push( + `Host port ${port} is held by another project's container ("${holder.name}") — ` + + `stop it (docker stop ${holder.name}) and start Teqo's (pnpm db:start), or point DATABASE_URL at another port.`, + ) + } else if (holder && /database .* does not exist/.test(message)) { + remedies.push( + `The server is Teqo's, but the database is missing. The compose init only creates databases on the FIRST ` + + `volume initialization — create it manually: docker exec ${holder.name} createdb -U teqo `, + ) + } + + return remedies +} + +/** + * Node's happy-eyeballs connect (::1 + 127.0.0.1) throws an AggregateError + * whose own message is EMPTY — the useful ECONNREFUSED lives in `.errors`. + */ +const describeError = (error) => { + if (error instanceof AggregateError && error.errors.length > 0) { + return [...new Set(error.errors.map(describeError))].join('; ') || error.message + } + if (error instanceof Error) return error.message || String(error) + return String(error) +} + +/** + * @param {{ label: string, databaseUrl: string }} target + * @returns {Promise} true when the database answered `SELECT 1`. + */ +export const diagnoseDatabaseTarget = async ({ label, databaseUrl }) => { + let url + try { + url = new URL(databaseUrl) + } catch { + console.error(`[${label}] DATABASE_URL is not a valid connection string.`) + return false + } + + const where = `${url.hostname}:${url.port || '5432'}${url.pathname}` + const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 2500 }) + + try { + await client.connect() + await client.query('SELECT 1') + console.log(`[${label}] OK — ${where} is up and answering.`) + return true + } catch (error) { + const message = describeError(error) + const port = Number(url.port || '5432') + const refused = /ECONNREFUSED|ENOTFOUND|timeout/i.test(message) + const containers = listContainers() + const remedies = refused + ? remediesForConnectionRefused(port, containers) + : remediesForBadServer(port, containers, message) + + console.error(`[${label}] FAIL — cannot use ${where} (${message})`) + for (const remedy of remedies) console.error(`[${label}] → ${remedy}`) + return false + } finally { + await client.end().catch(() => {}) + } +} + +/** Named volume a container mounts at the Postgres data directory, if any. */ +const postgresDataVolume = (name) => { + if (!isSafeContainerName(name)) return null + try { + const output = execFileSync( + 'docker', + [ + 'inspect', + '--format', + '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Name}}{{end}}{{end}}', + name, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ) + return output.trim() || null + } catch { + return null + } +} + +/** + * Two Postgres containers mounting the SAME data volume silently corrupt the + * WAL: the postmaster lock file cannot interlock across container PID + * namespaces, so both instances happily write one PGDATA. This exact setup + * (a manual `teqo-postgres` next to compose's `teqo-postgres-1`, both on + * `teqo_pgdata`) produced a checkpoint PANIC on 2026-07-25. + * + * @returns {boolean} true when no data volume is shared. + */ +export const warnOnSharedDataVolumes = () => { + const containers = listContainers() + if (!containers) return true + + const byVolume = new Map() + for (const container of containers) { + if (!container.image.includes('postgres')) continue + const volume = postgresDataVolume(container.name) + if (!volume) continue + byVolume.set(volume, [...(byVolume.get(volume) ?? []), container.name]) + } + + let clean = true + for (const [volume, names] of byVolume) { + if (names.length < 2) continue + clean = false + console.error( + `[doctor] DANGER — containers ${names.join(' and ')} mount the SAME data volume "${volume}". ` + + `Two Postgres instances on one PGDATA corrupt each other's WAL. ` + + `Keep the compose one (pnpm db:start) and remove the rest: docker rm -f .`, + ) + } + return clean +} + +const readEnvFile = (path) => { + try { + return parseEnv(readFileSync(path, 'utf8')) + } catch { + return {} + } +} + +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 + + let healthy = warnOnSharedDataVolumes() + if (devUrl) { + healthy = (await diagnoseDatabaseTarget({ label: 'dev', databaseUrl: devUrl })) && healthy + } else { + console.error('[dev] No DATABASE_URL found in the environment, .env.local or .env.') + healthy = false + } + if (testUrl) { + healthy = (await diagnoseDatabaseTarget({ label: 'test', databaseUrl: testUrl })) && healthy + } else { + console.error('[test] No DATABASE_URL found in .env.test.') + healthy = false + } + + process.exit(healthy ? 0 : 1) +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + await main() +} diff --git a/scripts/guard-dev-db.mjs b/scripts/guard-dev-db.mjs index b2d2c7e4..a4db0f4e 100644 --- a/scripts/guard-dev-db.mjs +++ b/scripts/guard-dev-db.mjs @@ -10,6 +10,7 @@ import { config as loadEnv } from 'dotenv' import { assertLocalDatabase } from './assert-local-database.mjs' +import { diagnoseDatabaseTarget, warnOnSharedDataVolumes } from './db-doctor.mjs' // Mirror Next.js precedence (.env.local wins over .env) WITHOUT overriding a // DATABASE_URL that is already set in the real environment (e.g. injected by @@ -24,3 +25,16 @@ assertLocalDatabase( ' 1. pnpm db:start\n' + ' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local', ) + +// Connectivity preflight: fail here with a named remedy (which container to +// start, who is holding the port) instead of letting Next/Payload throw a +// bare ECONNREFUSED minutes into the dev session. Skipped under the same +// escape hatch as the locality guard — remote databases are on their own. +if (process.env.ALLOW_REMOTE_DB !== 'true' && process.env.ALLOW_REMOTE_DB !== '1') { + const volumesClean = warnOnSharedDataVolumes() + const healthy = await diagnoseDatabaseTarget({ + label: 'guard-dev-db', + databaseUrl: process.env.DATABASE_URL, + }) + if (!healthy || !volumesClean) process.exit(1) +}