Tooling: pnpm db:doctor + preflight no guard-dev-db (remedios nomeados, deteccao de volume compartilhado pos-corrupcao WAL), madge check:cycles e prettier no CI/pre-push, guard rails de data migration no skill, notas de processo (knip --fix, pipes) e gate atualizado nos docs/rules

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 18:02:02 -03:00
parent 4bbabb7d5e
commit c00f83d870
11 changed files with 333 additions and 20 deletions

View file

@ -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`.

View file

@ -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 <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
## Type honesty

View file

@ -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

View file

@ -61,6 +61,14 @@ export async function down({ db }: MigrateDownArgs): Promise<void> {
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:

View file

@ -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

View file

@ -1 +1 @@
pnpm lint && pnpm typecheck
pnpm lint && pnpm format:check && pnpm typecheck

View file

@ -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 <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 (`.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 <symbol>`) 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.

View file

@ -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

View file

@ -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",

273
scripts/db-doctor.mjs Normal file
View file

@ -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 <database>`,
)
}
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<boolean>} 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 <name>.`,
)
}
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()
}

View file

@ -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)
}