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.
284 lines
9.5 KiB
JavaScript
284 lines
9.5 KiB
JavaScript
/**
|
|
* 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 {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = readTestTargetEnv()
|
|
|
|
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()
|
|
}
|