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>
135 lines
5.7 KiB
TypeScript
135 lines
5.7 KiB
TypeScript
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);
|
|
}
|
|
});
|