Corrige quatro defeitos de runtime do worker e dos scripts operacionais

Os quatro apareceram ao exercitar o layout de runtime da imagem — apenas
node_modules de produção, src, scripts e migrations — e nenhum aparecia na
suíte de testes nem no build. Os dois primeiros formavam uma armadilha mútua.

1. O CLI do `tsx` cria processo filho (PIDs medidos: 20840 contra 21096).
   O SIGTERM do `docker stop` pararia no pai, o worker nunca rodaria o
   handler, o lock esperaria o TTL de 120s e o `stop_grace_period` só
   atrasaria um SIGKILL. Passa a ser `node --import tsx`, mesmo processo.
2. Top-level await nos scripts falha com `--import tsx`, porque sem
   `"type": "module"` o esbuild gera CJS. `migrate`, `worker`, `seed-staging`,
   `flag`, `cleanup` e `check-bucket` não executariam: a migração não rodaria
   e o worker não subiria. `runScript`/`runWithDb` resolvem sem mudar o
   module type do projeto.
3. `import "server-only"` em `worker-loop.ts`: o pacote lança fora do bundler
   do Next, e o worker morria no start.
4. `@react-pdf/renderer` carregado no import de `process-job.ts` falha com
   ERR_PACKAGE_PATH_NOT_EXPORTED ao resolver a hifenização fora do bundler,
   derrubando worker e cleanup. O PDF sai sob demanda no download, então o
   import passou a ser tardio, dentro do ramo que o usa.

Além disso:

- O loop do worker saiu do script para `worker-loop.ts`, testável sem subir
  processo. O script cuida só de sinais, cadência e bootstrap.
- Desligamento gracioso espera o tick corrente para o `finally` liberar o
  lock. Um job que ainda não começou fica `queued`: nada pela metade.
- Heartbeat por tick, lido pelo healthcheck do contêiner. Processo vivo mas
  travado passa em qualquer verificação de PID.
- O worker recusa iniciar com migration pendente, e relê o contexto do dono a
  cada job: papel e feature flag podem ter mudado desde o enfileiramento.
- `verify-config`, `check-bucket`, `seed-staging` e `smoke-staging` novos. O
  smoke tem fase autenticada que só roda com credencial fornecida pela
  operação e, sem ela, reporta PENDENTE em vez de aprovado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:39:15 -03:00
1 parent d43f370359
commit ca8a1112f0
13 files changed
+835 -126

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { adapterEnv, isProductionEnv } from "../src/config/env";
import { migrate } from "../src/infra/db/migrate";
import type { Db } from "../src/infra/db/types";
import { bootstrapScript, describeTarget, openOperationalDb, runScript } from "./_bootstrap";
/**
* Seed idempotente de homologação.
*
* Não cria conta, senha ou sessão: as identidades vivem no Supabase Auth do
* projeto de homologação e são criadas pela operação no painel do Supabase.
* Este script apenas liga aqueles `user_id` já existentes a workspaces e
* papéis. Rodar duas vezes produz o mesmo estado.
*
* Arquivo: `ADAPTER_STAGING_SEED_FILE` (padrão `config/staging-seed.json`).
* Modelo sem segredo em `config/staging-seed.example.json`.
*/
type SeedWorkspace = { id: string; name: string; moduleEnabled: boolean; allowNonAdminModelUpload?: boolean };
type SeedUser = { id: string; displayName: string; isSuperadmin?: boolean };
type SeedMember = { workspaceId: string; userId: string; role: "owner" | "admin" | "gestor" | "agent" };
type SeedFile = { workspaces: SeedWorkspace[]; users: SeedUser[]; members: SeedMember[] };
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ROLES = new Set(["owner", "admin", "gestor", "agent"]);
function assertSeed(seed: SeedFile): void {
const workspaceIds = new Set<string>();
for (const workspace of seed.workspaces) {
if (!UUID_RE.test(workspace.id)) throw new Error(`workspace.id não é UUID: ${workspace.id}`);
if (!workspace.name?.trim()) throw new Error(`workspace ${workspace.id} sem nome`);
workspaceIds.add(workspace.id.toLowerCase());
}
const userIds = new Set<string>();
for (const user of seed.users) {
if (!UUID_RE.test(user.id)) {
throw new Error(`users[].id deve ser o UUID do usuário no Supabase Auth: ${user.id}`);
}
if (!user.displayName?.trim()) throw new Error(`usuário ${user.id} sem displayName`);
userIds.add(user.id.toLowerCase());
}
for (const member of seed.members) {
if (!ROLES.has(member.role)) throw new Error(`papel inválido: ${member.role}`);
if (!workspaceIds.has(member.workspaceId.toLowerCase())) {
throw new Error(`membro aponta workspace fora do seed: ${member.workspaceId}`);
}
if (!userIds.has(member.userId.toLowerCase())) {
throw new Error(`membro aponta usuário fora do seed: ${member.userId}`);
}
}
}
async function applySeed(db: Db, seed: SeedFile): Promise<void> {
await db.withService(async (query) => {
for (const workspace of seed.workspaces) {
await query.query(
`insert into public.workspaces (id, name) values ($1, $2)
on conflict (id) do update set name = excluded.name`,
[workspace.id, workspace.name],
);
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
values ($1, 'spreadsheet_adapter', $2)
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
[workspace.id, workspace.moduleEnabled],
);
if (workspace.allowNonAdminModelUpload !== undefined) {
await query.query(
`insert into public.spreadsheet_adapter_settings (workspace_id, allow_non_admin_model_upload)
values ($1, $2)
on conflict (workspace_id) do update set allow_non_admin_model_upload = excluded.allow_non_admin_model_upload`,
[workspace.id, workspace.allowNonAdminModelUpload],
);
}
}
for (const user of seed.users) {
await query.query(
`insert into public.profiles (id, display_name, is_superadmin) values ($1, $2, $3)
on conflict (id) do update set display_name = excluded.display_name, is_superadmin = excluded.is_superadmin`,
[user.id, user.displayName, user.isSuperadmin ?? false],
);
}
for (const member of seed.members) {
await query.query(
`insert into public.workspace_members (workspace_id, user_id, role) values ($1, $2, $3)
on conflict (workspace_id, user_id) do update set role = excluded.role`,
[member.workspaceId, member.userId, member.role],
);
}
});
}
bootstrapScript("seed-staging");
if (isProductionEnv()) {
console.error("seed-staging não roda em produção. As associações de produção são da plataforma Elo Pro.");
process.exit(1);
}
if (adapterEnv() === "development" && process.env.ADAPTER_SEED_FORCE !== "1") {
console.error("Em development use `npm run seed`. Para forçar este seed, defina ADAPTER_SEED_FORCE=1.");
process.exit(1);
}
const file = process.env.ADAPTER_STAGING_SEED_FILE ?? path.join("config", "staging-seed.json");
runScript("seed-staging", async () => {
let seed: SeedFile;
try {
seed = JSON.parse(await readFile(path.resolve(process.cwd(), file), "utf8")) as SeedFile;
} catch (error) {
console.error(
`Não foi possível ler o seed em "${file}". Copie config/staging-seed.example.json e preencha com os UUID do Supabase Auth.`,
);
console.error(error instanceof Error ? error.name : "erro");
process.exit(1);
}
seed.workspaces ??= [];
seed.users ??= [];
seed.members ??= [];
assertSeed(seed);
const db = await openOperationalDb();
try {
const ran = await migrate(db);
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
await applySeed(db, seed);
console.log(
`seed de homologação aplicado em ${describeTarget()}: ${seed.workspaces.length} workspace(s), ${seed.users.length} identidade(s), ${seed.members.length} associação(ões)`,
);
} finally {
await db.close().catch(() => undefined);
}
});