From b9725899499c839c7dd6b4a9c3c8018d7a3d1408 Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Thu, 15 Oct 2026 20:38:32 -0300 Subject: [PATCH] =?UTF-8?q?Introduz=20ADAPTER=5FENV=20e=20endurece=20confi?= =?UTF-8?q?gura=C3=A7=C3=A3o=20e=20infraestrutura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next start` força NODE_ENV=production, então homologação não tinha como se distinguir de produção: o login local ficava desligado (correto) e o módulo exigia o SSO do Elo Pro, que ainda não existe. Não havia terceiro caminho. - `ADAPTER_ENV` com development, staging e production, independente de NODE_ENV. Toda política passa a derivar dele. - Fail-fast de configuração em `fail-fast.ts`, separado de `env.ts` porque `process.exit` não existe no Edge Runtime. - `requireDatabaseUrl` elimina o fallback silencioso para PGlite: um segredo ausente em staging migrava um arquivo local e parecia sucesso. - Shim de identidade passa a ser decisão explícita via `ADAPTER_APPLY_IDENTITY_SHIM`; aplicá-lo sobre o schema real do Elo Pro é destrutivo em potencial. - `migration-manifest.ts` responde o estado do schema consultando só o banco. Ler o diretório numa rota fazia o Next tracear o projeto inteiro para a saída standalone, levando docs, testes e fontes para a imagem. - Redis e Supabase viram singleton por processo. Criar um ioredis por requisição vazava conexão: o cliente é lazy, abre no primeiro uso e nunca fecha. - Namespace de chaves deriva de ADAPTER_NAMESPACE e do ambiente lógico, não de SENTRY_ENVIRONMENT. Observabilidade e isolamento de dados são decisões diferentes, e a variável única fazia homologação e produção colidirem. - Listagem do bucket passa a paginar e descer em subpastas. Parando em 1000 entradas, a limpeza por TTL deixava de ver arquivos e temporários venciam sem nunca ser removidos. - `ping` e `hit` no coordenador, e `healthcheck` no armazenamento, para o health check tocar as dependências de verdade. Co-Authored-By: Claude Opus 5 --- src/config/env.ts | 161 +++++++++++++++++++++++++---- src/config/fail-fast.ts | 19 ++++ src/config/retention.ts | 6 +- src/infra/db/client.ts | 31 ++++-- src/infra/db/migrate.ts | 57 +++++++++- src/infra/db/migration-manifest.ts | 39 +++++++ src/infra/redis/index.ts | 29 +++++- src/infra/redis/memory.ts | 27 +++++ src/infra/redis/redis.ts | 38 ++++++- src/infra/storage/index.ts | 35 ++++++- src/infra/storage/local.ts | 9 ++ src/infra/storage/memory.ts | 7 ++ src/infra/storage/supabase.ts | 59 ++++++++++- 13 files changed, 474 insertions(+), 43 deletions(-) create mode 100644 src/config/fail-fast.ts create mode 100644 src/infra/db/migration-manifest.ts diff --git a/src/config/env.ts b/src/config/env.ts index 57480b2..7b24be3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -2,31 +2,110 @@ export type EnvMap = Record; export type EnvIssue = { variable: string; message: string }; -const REQUIRED_IN_PRODUCTION = [ +/** + * Ambiente lógico do Adaptador. É independente de NODE_ENV porque homologação + * roda com NODE_ENV=production (build otimizado) mas com política própria. + */ +export const ADAPTER_ENVS = ["development", "staging", "production"] as const; +export type AdapterEnv = (typeof ADAPTER_ENVS)[number]; + +const DEFAULT_NAMESPACE = "adaptador-planilhas"; + +/** Variáveis exigidas em qualquer ambiente implantado (staging e produção). */ +const REQUIRED_WHEN_DEPLOYED = [ "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_STORAGE_BUCKET", "DATABASE_URL", "REDIS_URL", - "SENTRY_DSN", - "ELO_PRO_LOGIN_URL", "SPREADSHEET_TEMP_TTL_SECONDS", ] as const; -export function collectProductionIssues(env: EnvMap): EnvIssue[] { - if (env.NODE_ENV !== "production") return []; +/** Variáveis exigidas somente em produção. */ +const REQUIRED_IN_PRODUCTION_ONLY = ["SENTRY_DSN", "ELO_PRO_LOGIN_URL"] as const; + +export function adapterEnv(env: EnvMap = process.env): AdapterEnv { + const raw = env.ADAPTER_ENV?.trim(); + if (raw && (ADAPTER_ENVS as readonly string[]).includes(raw)) return raw as AdapterEnv; + if (raw) return "production"; + return env.NODE_ENV === "production" ? "production" : "development"; +} + +/** Ambiente servido para outras pessoas. Exige configuração completa. */ +export function isDeployedEnv(env: EnvMap = process.env): boolean { + return adapterEnv(env) !== "development"; +} + +export function isProductionEnv(env: EnvMap = process.env): boolean { + return adapterEnv(env) === "production"; +} + +export function isStagingEnv(env: EnvMap = process.env): boolean { + return adapterEnv(env) === "staging"; +} + +/** Namespace de chaves externas. Separa aplicativo e ambiente no servidor compartilhado. */ +export function resourceNamespace(env: EnvMap = process.env): string { + const app = env.ADAPTER_NAMESPACE?.trim() || DEFAULT_NAMESPACE; + return `${app}:${adapterEnv(env)}`; +} + +/** + * Decisão explícita sobre o shim de identidade (`profiles`, `workspaces`, + * `workspace_members`). Em ambiente implantado nunca é implícita: aplicar o shim + * sobre o schema real do Elo Pro é destrutivo em potencial. + */ +export function identityShimEnabled(env: EnvMap = process.env): boolean { + const raw = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim(); + if (raw === "1" || raw === "true") return true; + if (raw === "0" || raw === "false") return false; + return !isDeployedEnv(env); +} + +/** Login próprio de homologação, com Supabase Auth real. Nunca em produção. */ +export function stagingAuthEnabled(env: EnvMap = process.env): boolean { + return ( + isStagingEnv(env) && + env.ADAPTER_STAGING_AUTH === "1" && + Boolean(env.NEXT_PUBLIC_SUPABASE_URL) && + Boolean(env.NEXT_PUBLIC_SUPABASE_ANON_KEY) + ); +} + +export function collectConfigIssues(env: EnvMap = process.env): EnvIssue[] { + const target = adapterEnv(env); + const raw = env.ADAPTER_ENV?.trim(); const issues: EnvIssue[] = []; - for (const variable of REQUIRED_IN_PRODUCTION) { + + if (raw && !(ADAPTER_ENVS as readonly string[]).includes(raw)) { + issues.push({ variable: "ADAPTER_ENV", message: `valor inválido. Use ${ADAPTER_ENVS.join(", ")}` }); + } + if (target === "development") return issues; + + for (const variable of REQUIRED_WHEN_DEPLOYED) { if (!env[variable] || env[variable]?.trim() === "") { - issues.push({ variable, message: "obrigatória em produção" }); + issues.push({ variable, message: `obrigatória em ${target}` }); + } + } + if (target === "production") { + for (const variable of REQUIRED_IN_PRODUCTION_ONLY) { + if (!env[variable] || env[variable]?.trim() === "") { + issues.push({ variable, message: "obrigatória em produção" }); + } } } if (env.ADAPTER_DEV_AUTH === "1") { - issues.push({ variable: "ADAPTER_DEV_AUTH", message: "não pode estar ligado em produção" }); + issues.push({ variable: "ADAPTER_DEV_AUTH", message: `só existe em development, nunca em ${target}` }); } if (env.ADAPTER_JOB_MODE === "inline") { - issues.push({ variable: "ADAPTER_JOB_MODE", message: "inline não é permitido em produção" }); + issues.push({ variable: "ADAPTER_JOB_MODE", message: `inline não é permitido em ${target}` }); + } + if (env.ADAPTER_APPLY_IDENTITY_SHIM === undefined || env.ADAPTER_APPLY_IDENTITY_SHIM.trim() === "") { + issues.push({ + variable: "ADAPTER_APPLY_IDENTITY_SHIM", + message: "decisão explícita obrigatória. 1 cria o shim de identidade, 0 usa o schema existente", + }); } const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS; if (ttl && ttl.trim() !== "") { @@ -38,22 +117,68 @@ export function collectProductionIssues(env: EnvMap): EnvIssue[] { if (env.SUPABASE_STORAGE_BUCKET === "public") { issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" }); } + if (target === "production" && env.ADAPTER_STAGING_AUTH === "1") { + issues.push({ variable: "ADAPTER_STAGING_AUTH", message: "login próprio de homologação não vai a produção" }); + } + if (target === "staging" && env.ADAPTER_STAGING_AUTH !== "1" && !env.ELO_PRO_LOGIN_URL) { + issues.push({ + variable: "ADAPTER_STAGING_AUTH", + message: "homologação precisa de ADAPTER_STAGING_AUTH=1 ou de ELO_PRO_LOGIN_URL", + }); + } return issues; } -export function assertProductionEnv(env: EnvMap = process.env): void { - if (env.NODE_ENV !== "production") return; - if (env.NEXT_PHASE === "phase-production-build") return; - if (env.npm_lifecycle_event === "build") return; - const issues = collectProductionIssues(env); +/** Mantido para compatibilidade com chamadas e testes existentes. */ +export function collectProductionIssues(env: EnvMap = process.env): EnvIssue[] { + return collectConfigIssues(env); +} + +export function formatConfigIssues(issues: EnvIssue[]): string { + return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; "); +} + +function skipAssertion(env: EnvMap): boolean { + if (env.NEXT_PHASE === "phase-production-build") return true; + if (env.npm_lifecycle_event === "build") return true; + return env.VITEST === "true" || env.NODE_ENV === "test"; +} + +/** + * Recusa a configuração inválida. Lança: quem chama decide entre encerrar o + * processo (servidor e worker) ou apenas reportar (health check). + */ +export function assertDeployedEnv(env: EnvMap = process.env): void { + if (!isDeployedEnv(env)) return; + if (skipAssertion(env)) return; + const issues = collectConfigIssues(env); if (issues.length === 0) return; - const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; "); - throw new Error(`Configuração de produção recusada. ${summary}`); + throw new Error(`Configuração de ${adapterEnv(env)} recusada. ${formatConfigIssues(issues)}`); +} + +/** Mantido para compatibilidade. */ +export function assertProductionEnv(env: EnvMap = process.env): void { + assertDeployedEnv(env); } export function jobRunsInline(env: EnvMap = process.env): boolean { - if (env.NODE_ENV === "production" && env.ADAPTER_JOB_MODE === "inline") return false; + if (isDeployedEnv(env)) return false; if (env.ADAPTER_JOB_MODE === "worker") return false; if (env.ADAPTER_JOB_MODE === "inline") return true; - return env.NODE_ENV !== "production"; + return true; +} + +/** + * Connection string obrigatória fora de development. Sem isto um segredo + * ausente viraria uma migração silenciosa num arquivo PGlite local. + */ +export function requireDatabaseUrl(env: EnvMap = process.env): string | null { + const databaseUrl = env.DATABASE_URL?.trim(); + if (databaseUrl) return databaseUrl; + if (isDeployedEnv(env)) { + throw new Error( + `DATABASE_URL é obrigatória em ${adapterEnv(env)}. O banco local PGlite só existe em development.`, + ); + } + return null; } diff --git a/src/config/fail-fast.ts b/src/config/fail-fast.ts new file mode 100644 index 0000000..b66ddf7 --- /dev/null +++ b/src/config/fail-fast.ts @@ -0,0 +1,19 @@ +import { assertDeployedEnv, type EnvMap } from "./env"; + +/** + * Encerra o processo com código diferente de zero quando a configuração é + * inválida. Um servidor que fica no ar respondendo 500 em todas as rotas engana + * o orquestrador e o health check: é pior que não subir. + * + * Mora fora de `env.ts` porque `process.exit` não existe no Edge Runtime, e + * `env.ts` é importado também pelo bundle de edge. + */ +export function exitOnInvalidConfig(env: EnvMap = process.env): void { + try { + assertDeployedEnv(env); + } catch (error) { + const message = error instanceof Error ? error.message : "Configuração recusada."; + console.error(`[adaptador-planilhas] ${message}`); + process.exit(1); + } +} diff --git a/src/config/retention.ts b/src/config/retention.ts index c232c6c..6d7b503 100644 --- a/src/config/retention.ts +++ b/src/config/retention.ts @@ -1,3 +1,5 @@ +import { adapterEnv, isDeployedEnv } from "./env"; + /** TTL de temporários. O número de produto ainda é decisão aberta. */ export function tempTtlSeconds(): number { const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS; @@ -8,9 +10,9 @@ export function tempTtlSeconds(): number { } return value; } - if (process.env.NODE_ENV === "production") { + if (isDeployedEnv() || process.env.NODE_ENV === "production") { throw new Error( - "SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em produção. A retenção de temporários continua decisão aberta.", + `SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em ${adapterEnv()}. A retenção de temporários continua decisão aberta.`, ); } return 60 * 60; diff --git a/src/infra/db/client.ts b/src/infra/db/client.ts index 360e68b..df85f5f 100644 --- a/src/infra/db/client.ts +++ b/src/infra/db/client.ts @@ -1,8 +1,9 @@ import "server-only"; +import { mkdir } from "node:fs/promises"; import path from "node:path"; +import { isDeployedEnv, requireDatabaseUrl } from "@/config/env"; import { createFileDb } from "./pglite"; import { createPostgresDb } from "./postgres"; -import { migrate } from "./migrate"; import type { Db } from "./types"; let singleton: Promise | null = null; @@ -10,15 +11,33 @@ let singleton: Promise | null = null; export async function getDb(): Promise { if (!singleton) { singleton = (async () => { - const databaseUrl = process.env.DATABASE_URL; + // Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite. + const databaseUrl = requireDatabaseUrl(); if (databaseUrl) return createPostgresDb(databaseUrl); - if (process.env.NODE_ENV === "production") { - throw new Error("DATABASE_URL é obrigatório em produção."); + if (isDeployedEnv()) { + throw new Error("DATABASE_URL é obrigatória fora de development."); } - const db = await createFileDb(path.join(process.cwd(), ".data", "pglite")); + // Cria o diretório antes: na primeira execução o PGlite falhava ao criar + // a árvore sozinho, e o health check reportava o banco fora do ar. + const dataDir = path.join(process.cwd(), ".data", "pglite"); + await mkdir(dataDir, { recursive: true }); + const db = await createFileDb(dataDir); + // Migrations automáticas existem apenas no banco local de development. + // Import dinâmico: o executor lê o diretório `migrations/`, e manter essa + // leitura fora do grafo das rotas evita o tracing do projeto inteiro. + const { migrate } = await import("./migrate"); await migrate(db); return db; - })(); + })().catch((error) => { + singleton = null; + throw error; + }); } return singleton; } + +/** Usado pelos health checks. Não cria o banco local nem aplica migrations. */ +export async function probeDb(): Promise { + const db = await getDb(); + await db.withService((query) => query.query("select 1 as ok")); +} diff --git a/src/infra/db/migrate.ts b/src/infra/db/migrate.ts index 5ff10e9..014cb10 100644 --- a/src/infra/db/migrate.ts +++ b/src/infra/db/migrate.ts @@ -1,28 +1,77 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { identityShimEnabled } from "@/config/env"; +import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest"; import type { Db } from "./types"; +/** + * Executor de migrations. Lê o diretório `migrations/`, por isso só é usado + * pelos scripts operacionais e pelo banco local de development — nunca por uma + * rota. Quem precisa saber o estado do schema em runtime usa + * `migration-manifest.ts`, que só consulta o banco. + * + * O caminho é estaticamente limitado a `/migrations`: montar o caminho a + * partir de uma variável fazia o Next tracear o projeto inteiro para dentro da + * saída `standalone`, levando docs, testes e fontes para a imagem. + */ + +const MIGRATIONS_FOLDER = "migrations"; + +export type MigrateOptions = { + /** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */ + applyIdentityShim?: boolean; +}; + export function migrationsDir(): string { - return path.join(process.cwd(), "migrations"); + return path.join(process.cwd(), MIGRATIONS_FOLDER); } -export async function migrate(db: Db, directory = migrationsDir()): Promise { +export async function listMigrationFiles(): Promise { + const entries = await readdir(path.join(process.cwd(), MIGRATIONS_FOLDER)); + return entries.filter((file) => file.endsWith(".sql")).sort(); +} + +/** + * O manifesto estático e o diretório precisam contar a mesma história. Um + * arquivo novo sem entrada no manifesto passaria em branco pelo health check. + */ +export function assertManifestMatches(files: string[]): void { + const expected: string[] = [...EXPECTED_MIGRATIONS]; + const extra = files.filter((file) => !expected.includes(file)); + const missing = expected.filter((file) => !files.includes(file)); + if (extra.length === 0 && missing.length === 0) return; + const parts = [ + extra.length ? `fora do manifesto: ${extra.join(", ")}` : "", + missing.length ? `ausentes no diretório: ${missing.join(", ")}` : "", + ].filter(Boolean); + throw new Error( + `migrations/ e EXPECTED_MIGRATIONS divergiram (${parts.join("; ")}). Atualize src/infra/db/migration-manifest.ts.`, + ); +} + +export async function migrate(db: Db, options: MigrateOptions = {}): Promise { + const applyShim = options.applyIdentityShim ?? identityShimEnabled(); + await db.exec(` create table if not exists public.schema_migrations ( id text primary key, applied_at timestamptz not null default now() ); `); - const files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort(); + const files = await listMigrationFiles(); + assertManifestMatches(files); const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations")); const applied = new Set(appliedRows.map((row) => row.id)); const ran: string[] = []; for (const file of files) { if (applied.has(file)) continue; - const sql = await readFile(path.join(directory, file), "utf8"); + if (isShimMigration(file) && !applyShim) continue; + const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"); await db.exec(sql); await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file])); ran.push(file); } return ran; } + +export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest"; diff --git a/src/infra/db/migration-manifest.ts b/src/infra/db/migration-manifest.ts new file mode 100644 index 0000000..1886e06 --- /dev/null +++ b/src/infra/db/migration-manifest.ts @@ -0,0 +1,39 @@ +import { identityShimEnabled } from "@/config/env"; +import type { Db } from "./types"; + +/** + * Migrations que **este build** espera encontrar aplicadas. + * + * É uma lista estática de propósito. O health check e o worker precisam + * responder "o banco está na versão que este código espera?", não "o banco + * combina com os arquivos que por acaso estão no disco". Ler o diretório aqui + * também fazia o Next tracear o projeto inteiro para dentro da saída + * `standalone`. + * + * `migrate()` confere que esta lista e o diretório `migrations/` não divergiram. + */ +export const EXPECTED_MIGRATIONS = [ + "0001_platform_shim.sql", + "0002_spreadsheet_adapter.sql", + "0003_rls.sql", + "0004_audit_append_only.sql", +] as const; + +/** Migrations que criam o shim local de identidade do Elo Pro. */ +export const SHIM_MIGRATIONS: ReadonlySet = new Set(["0001_platform_shim.sql"]); + +export function isShimMigration(file: string): boolean { + return SHIM_MIGRATIONS.has(file); +} + +export type PendingOptions = { applyIdentityShim?: boolean }; + +/** Migrations esperadas que ainda não constam em `schema_migrations`. */ +export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise { + const applyShim = options.applyIdentityShim ?? identityShimEnabled(); + const appliedRows = await db + .withService((query) => query.query<{ id: string }>("select id from public.schema_migrations")) + .catch(() => [] as { id: string }[]); + const applied = new Set(appliedRows.map((row) => row.id)); + return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file))); +} diff --git a/src/infra/redis/index.ts b/src/infra/redis/index.ts index ef403b1..fb996b2 100644 --- a/src/infra/redis/index.ts +++ b/src/infra/redis/index.ts @@ -1,8 +1,33 @@ +import { isDeployedEnv } from "@/config/env"; import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory"; import { RedisCoordinator } from "./redis"; +let singleton: Coordinator | null = null; +let singletonKey: string | null = null; + +/** + * Coordenador reutilizado pelo processo. Criar um `ioredis` por requisição + * vazava conexão: o cliente é lazy, abre no primeiro uso e nunca fecha. + */ export function createCoordinator(): Coordinator { - if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL); - if (process.env.NODE_ENV === "production") return new UnavailableCoordinator(); + const url = process.env.REDIS_URL ?? ""; + const key = `${url}|${isDeployedEnv() ? "deployed" : "local"}`; + if (singleton && singletonKey === key) return singleton; + singletonKey = key; + singleton = buildCoordinator(url); + return singleton; +} + +function buildCoordinator(url: string): Coordinator { + if (url) return new RedisCoordinator(url); + if (isDeployedEnv()) return new UnavailableCoordinator(); return new MemoryCoordinator(); } + +/** Usado em testes para não vazar estado entre casos. */ +export function resetCoordinator(): void { + singleton = null; + singletonKey = null; +} + +export type { Coordinator }; diff --git a/src/infra/redis/memory.ts b/src/infra/redis/memory.ts index d838e58..20ca250 100644 --- a/src/infra/redis/memory.ts +++ b/src/infra/redis/memory.ts @@ -2,6 +2,10 @@ import type { JobProgress } from "@/domain/spreadsheet-adapter/types"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; export interface Coordinator { + /** Verdadeiro quando a coordenação responde. Usado pelo health check. */ + ping(): Promise; + /** Contador por janela. Base do rate limiting. Retorna o total da janela. */ + hit(key: string, windowSeconds: number): Promise; acquireLock(key: string, owner: string, ttlSeconds: number): Promise; renewLock(key: string, owner: string, ttlSeconds: number): Promise; ownsLock(key: string, owner: string): Promise; @@ -17,10 +21,25 @@ type LockRecord = { owner: string; expiresAt: number }; export class MemoryCoordinator implements Coordinator { private readonly locks = new Map(); private readonly progress = new Map(); + private readonly counters = new Map(); private readonly queue: string[] = []; constructor(private readonly now: () => number = () => Date.now()) {} + async ping(): Promise { + return true; + } + + async hit(key: string, windowSeconds: number): Promise { + const current = this.counters.get(key); + if (!current || current.expiresAt <= this.now()) { + this.counters.set(key, { count: 1, expiresAt: this.now() + windowSeconds * 1000 }); + return 1; + } + current.count += 1; + return current.count; + } + private live(key: string): LockRecord | null { const record = this.locks.get(key); if (!record) return null; @@ -76,6 +95,14 @@ export class UnavailableCoordinator implements Coordinator { throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503); } + async ping(): Promise { + return false; + } + + hit(): Promise { + this.fail(); + } + acquireLock(): Promise { this.fail(); } diff --git a/src/infra/redis/redis.ts b/src/infra/redis/redis.ts index 30dd076..d1c8a48 100644 --- a/src/infra/redis/redis.ts +++ b/src/infra/redis/redis.ts @@ -1,10 +1,12 @@ import Redis from "ioredis"; +import { resourceNamespace } from "@/config/env"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import type { JobProgress } from "@/domain/spreadsheet-adapter/types"; import type { Coordinator } from "./memory"; const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`; const RENEW = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`; +const HIT = `local n = redis.call("incr", KEYS[1]) if n == 1 then redis.call("expire", KEYS[1], ARGV[1]) end return n`; export class RedisCoordinator implements Coordinator { private readonly redis: Redis; @@ -15,6 +17,8 @@ export class RedisCoordinator implements Coordinator { enableOfflineQueue: false, connectTimeout: 2000, lazyConnect: true, + // Separa este aplicativo dos demais do ecossistema no Redis compartilhado. + keyPrefix: "", }); this.redis.on("error", () => undefined); } @@ -29,6 +33,19 @@ export class RedisCoordinator implements Coordinator { } } + async ping(): Promise { + try { + if (this.redis.status === "wait") await this.redis.connect(); + return (await this.redis.ping()) === "PONG"; + } catch { + return false; + } + } + + async hit(key: string, windowSeconds: number): Promise { + return this.run(async () => Number(await this.redis.eval(HIT, 1, key, String(windowSeconds)))); + } + async acquireLock(key: string, owner: string, ttlSeconds: number): Promise { return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK"); } @@ -69,20 +86,33 @@ export class RedisCoordinator implements Coordinator { async dequeue(): Promise { return this.run(async () => this.redis.rpop(queueKey())); } + + async close(): Promise { + await this.redis.quit().catch(() => undefined); + } } +/** + * Prefixo de todas as chaves. Deriva de `ADAPTER_NAMESPACE` e do ambiente + * lógico, não de `SENTRY_ENVIRONMENT`: observabilidade e isolamento de dados + * são decisões diferentes, e o servidor é compartilhado com outros aplicativos. + */ export function envNamespace(): string { - return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local"; + return resourceNamespace(); } export function lockKey(userId: string): string { - return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`; + return `${envNamespace()}:lock:user:${userId}`; } export function progressKey(jobId: string): string { - return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`; + return `${envNamespace()}:progress:${jobId}`; } export function queueKey(): string { - return `spreadsheet-adapter:${envNamespace()}:queue`; + return `${envNamespace()}:queue`; +} + +export function rateLimitKey(bucket: string, subject: string, windowStart: number): string { + return `${envNamespace()}:ratelimit:${bucket}:${subject}:${windowStart}`; } diff --git a/src/infra/storage/index.ts b/src/infra/storage/index.ts index 8b7249e..a17b895 100644 --- a/src/infra/storage/index.ts +++ b/src/infra/storage/index.ts @@ -1,15 +1,42 @@ import path from "node:path"; +import { isDeployedEnv } from "@/config/env"; import { LocalPrivateStorage } from "./local"; import { createSupabaseStorage } from "./supabase"; import type { ObjectStorage } from "./memory"; +let singleton: ObjectStorage | null = null; +let singletonKey: string | null = null; + +/** + * Armazenamento reutilizado pelo processo. O cliente Supabase mantém pool + * de conexões HTTP: criar um por requisição desperdiça soquete. + */ export function createStorage(): ObjectStorage { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const bucket = process.env.SUPABASE_STORAGE_BUCKET; - if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket); - if (process.env.NODE_ENV === "production") { - throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta."); + const key = `${url ?? ""}|${bucket ?? ""}|${serviceKey ? "svc" : "nosvc"}`; + if (singleton && singletonKey === key) return singleton; + + if (url && serviceKey && bucket) { + singletonKey = key; + singleton = createSupabaseStorage(url, serviceKey, bucket); + return singleton; } - return new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage")); + if (isDeployedEnv()) { + throw new Error( + "Armazenamento privado não configurado. Defina NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY e SUPABASE_STORAGE_BUCKET.", + ); + } + singletonKey = key; + singleton = new LocalPrivateStorage(path.join(process.cwd(), ".data", "storage")); + return singleton; } + +/** Usado em testes para não vazar estado entre casos. */ +export function resetStorage(): void { + singleton = null; + singletonKey = null; +} + +export type { ObjectStorage }; diff --git a/src/infra/storage/local.ts b/src/infra/storage/local.ts index f8cf4ba..b46c455 100644 --- a/src/infra/storage/local.ts +++ b/src/infra/storage/local.ts @@ -61,4 +61,13 @@ export class LocalPrivateStorage implements ObjectStorage { await walk(start); return found.filter((key) => key.startsWith(prefix.replace(/\/$/, ""))); } + + async healthcheck(): Promise { + try { + await mkdir(this.root, { recursive: true }); + return true; + } catch { + return false; + } + } } diff --git a/src/infra/storage/memory.ts b/src/infra/storage/memory.ts index 82dcf77..ee7de6c 100644 --- a/src/infra/storage/memory.ts +++ b/src/infra/storage/memory.ts @@ -5,7 +5,10 @@ export interface ObjectStorage { put(key: string, body: Buffer): Promise; get(key: string): Promise; delete(key: string): Promise; + /** Listagem completa do prefixo. Implementações remotas devem paginar. */ list(prefix: string): Promise; + /** Verdadeiro quando o bucket responde. Usado pelo health check. */ + healthcheck(): Promise; } export class MemoryStorage implements ObjectStorage { @@ -31,4 +34,8 @@ export class MemoryStorage implements ObjectStorage { async list(prefix: string): Promise { return [...this.objects.keys()].filter((key) => key.startsWith(prefix)); } + + async healthcheck(): Promise { + return true; + } } diff --git a/src/infra/storage/supabase.ts b/src/infra/storage/supabase.ts index 9def7f2..008fdfe 100644 --- a/src/infra/storage/supabase.ts +++ b/src/infra/storage/supabase.ts @@ -3,6 +3,11 @@ import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames"; import type { ObjectStorage } from "./memory"; +/** Página da API de listagem do Supabase Storage. */ +const LIST_PAGE = 1000; +/** Teto de segurança para não varrer indefinidamente um prefixo corrompido. */ +const LIST_MAX_PAGES = 100; + export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage { const client: SupabaseClient = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false }, @@ -24,11 +29,59 @@ export function createSupabaseStorage(url: string, serviceKey: string, bucket: s assertStorageKey(key); await bucketApi().remove([key]); }, + /** + * Listagem paginada e recursiva. A API devolve no máximo 1000 entradas por + * chamada: sem paginar, a limpeza por TTL deixava de ver arquivos acima + * disso e temporários venciam sem nunca serem removidos. + */ async list(prefix) { const folder = prefix.replace(/\/$/, ""); - const { data, error } = await bucketApi().list(folder, { limit: 1000 }); - if (error || !data) return []; - return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`); + const found: string[] = []; + const pending: string[] = [folder]; + let pages = 0; + while (pending.length > 0 && pages < LIST_MAX_PAGES) { + const current = pending.shift() as string; + let offset = 0; + for (;;) { + pages += 1; + if (pages > LIST_MAX_PAGES) break; + const { data, error } = await bucketApi().list(current, { limit: LIST_PAGE, offset }); + if (error || !data) break; + for (const item of data) { + if (!item.name) continue; + const key = current ? `${current}/${item.name}` : item.name; + // Entradas sem metadado de objeto são pastas: entram na fila. + if (item.id === null || item.id === undefined) pending.push(key); + else found.push(key); + } + if (data.length < LIST_PAGE) break; + offset += data.length; + } + } + return found.filter((key) => key.startsWith(folder)); + }, + async healthcheck() { + try { + const { error } = await bucketApi().list("", { limit: 1 }); + return !error; + } catch { + return false; + } }, }; } + +/** + * O bucket precisa ser privado. Um bucket público torna qualquer chave um + * link permanente e contorna a rota autenticada de download. + */ +export async function assertPrivateBucket(url: string, serviceKey: string, bucket: string): Promise { + const client = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } }); + const { data, error } = await client.storage.getBucket(bucket); + if (error || !data) { + throw new Error(`Bucket "${bucket}" não encontrado com a chave de serviço informada.`); + } + if (data.public) { + throw new Error(`Bucket "${bucket}" é público. O Adaptador exige bucket privado.`); + } +}