Introduz ADAPTER_ENV e endurece configuração e infraestrutura

`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 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:38:32 -03:00
1 parent 419325db12
commit b972589949
13 files changed
+474 -43

No files matched your search

+143 -18
View File
@@ -2,31 +2,110 @@ export type EnvMap = Record<string, string | undefined>;
export type EnvIssue = { variable: string; message: string }; 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_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_STORAGE_BUCKET", "SUPABASE_STORAGE_BUCKET",
"DATABASE_URL", "DATABASE_URL",
"REDIS_URL", "REDIS_URL",
"SENTRY_DSN",
"ELO_PRO_LOGIN_URL",
"SPREADSHEET_TEMP_TTL_SECONDS", "SPREADSHEET_TEMP_TTL_SECONDS",
] as const; ] as const;
export function collectProductionIssues(env: EnvMap): EnvIssue[] { /** Variáveis exigidas somente em produção. */
if (env.NODE_ENV !== "production") return []; 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[] = []; 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() === "") { 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") { 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") { 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; const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
if (ttl && ttl.trim() !== "") { if (ttl && ttl.trim() !== "") {
@@ -38,22 +117,68 @@ export function collectProductionIssues(env: EnvMap): EnvIssue[] {
if (env.SUPABASE_STORAGE_BUCKET === "public") { if (env.SUPABASE_STORAGE_BUCKET === "public") {
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" }); 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; return issues;
} }
export function assertProductionEnv(env: EnvMap = process.env): void { /** Mantido para compatibilidade com chamadas e testes existentes. */
if (env.NODE_ENV !== "production") return; export function collectProductionIssues(env: EnvMap = process.env): EnvIssue[] {
if (env.NEXT_PHASE === "phase-production-build") return; return collectConfigIssues(env);
if (env.npm_lifecycle_event === "build") return; }
const issues = collectProductionIssues(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; if (issues.length === 0) return;
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; "); throw new Error(`Configuração de ${adapterEnv(env)} recusada. ${formatConfigIssues(issues)}`);
throw new Error(`Configuração de produção recusada. ${summary}`); }
/** Mantido para compatibilidade. */
export function assertProductionEnv(env: EnvMap = process.env): void {
assertDeployedEnv(env);
} }
export function jobRunsInline(env: EnvMap = process.env): boolean { 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 === "worker") return false;
if (env.ADAPTER_JOB_MODE === "inline") return true; 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;
} }
+19
View File
@@ -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);
}
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { adapterEnv, isDeployedEnv } from "./env";
/** TTL de temporários. O número de produto ainda é decisão aberta. */ /** TTL de temporários. O número de produto ainda é decisão aberta. */
export function tempTtlSeconds(): number { export function tempTtlSeconds(): number {
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS; const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
@@ -8,9 +10,9 @@ export function tempTtlSeconds(): number {
} }
return value; return value;
} }
if (process.env.NODE_ENV === "production") { if (isDeployedEnv() || process.env.NODE_ENV === "production") {
throw new Error( 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; return 60 * 60;
+25 -6
View File
@@ -1,8 +1,9 @@
import "server-only"; import "server-only";
import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
import { createFileDb } from "./pglite"; import { createFileDb } from "./pglite";
import { createPostgresDb } from "./postgres"; import { createPostgresDb } from "./postgres";
import { migrate } from "./migrate";
import type { Db } from "./types"; import type { Db } from "./types";
let singleton: Promise<Db> | null = null; let singleton: Promise<Db> | null = null;
@@ -10,15 +11,33 @@ let singleton: Promise<Db> | null = null;
export async function getDb(): Promise<Db> { export async function getDb(): Promise<Db> {
if (!singleton) { if (!singleton) {
singleton = (async () => { 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 (databaseUrl) return createPostgresDb(databaseUrl);
if (process.env.NODE_ENV === "production") { if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatório em produção."); 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); await migrate(db);
return db; return db;
})(); })().catch((error) => {
singleton = null;
throw error;
});
} }
return singleton; return singleton;
} }
/** Usado pelos health checks. Não cria o banco local nem aplica migrations. */
export async function probeDb(): Promise<void> {
const db = await getDb();
await db.withService((query) => query.query("select 1 as ok"));
}
+53 -4
View File
@@ -1,28 +1,77 @@
import { readFile, readdir } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { identityShimEnabled } from "@/config/env";
import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest";
import type { Db } from "./types"; 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 `<cwd>/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 { 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<string[]> { export async function listMigrationFiles(): Promise<string[]> {
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<string[]> {
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
await db.exec(` await db.exec(`
create table if not exists public.schema_migrations ( create table if not exists public.schema_migrations (
id text primary key, id text primary key,
applied_at timestamptz not null default now() 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 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 applied = new Set(appliedRows.map((row) => row.id));
const ran: string[] = []; const ran: string[] = [];
for (const file of files) { for (const file of files) {
if (applied.has(file)) continue; 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.exec(sql);
await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file])); await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file]));
ran.push(file); ran.push(file);
} }
return ran; return ran;
} }
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
+39
View File
@@ -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<string> = 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<string[]> {
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)));
}
+27 -2
View File
@@ -1,8 +1,33 @@
import { isDeployedEnv } from "@/config/env";
import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory"; import { UnavailableCoordinator, MemoryCoordinator, type Coordinator } from "./memory";
import { RedisCoordinator } from "./redis"; 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 { export function createCoordinator(): Coordinator {
if (process.env.REDIS_URL) return new RedisCoordinator(process.env.REDIS_URL); const url = process.env.REDIS_URL ?? "";
if (process.env.NODE_ENV === "production") return new UnavailableCoordinator(); 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(); return new MemoryCoordinator();
} }
/** Usado em testes para não vazar estado entre casos. */
export function resetCoordinator(): void {
singleton = null;
singletonKey = null;
}
export type { Coordinator };
+27
View File
@@ -2,6 +2,10 @@ import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
export interface Coordinator { export interface Coordinator {
/** Verdadeiro quando a coordenação responde. Usado pelo health check. */
ping(): Promise<boolean>;
/** Contador por janela. Base do rate limiting. Retorna o total da janela. */
hit(key: string, windowSeconds: number): Promise<number>;
acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>; acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>; renewLock(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
ownsLock(key: string, owner: string): Promise<boolean>; ownsLock(key: string, owner: string): Promise<boolean>;
@@ -17,10 +21,25 @@ type LockRecord = { owner: string; expiresAt: number };
export class MemoryCoordinator implements Coordinator { export class MemoryCoordinator implements Coordinator {
private readonly locks = new Map<string, LockRecord>(); private readonly locks = new Map<string, LockRecord>();
private readonly progress = new Map<string, JobProgress>(); private readonly progress = new Map<string, JobProgress>();
private readonly counters = new Map<string, { count: number; expiresAt: number }>();
private readonly queue: string[] = []; private readonly queue: string[] = [];
constructor(private readonly now: () => number = () => Date.now()) {} constructor(private readonly now: () => number = () => Date.now()) {}
async ping(): Promise<boolean> {
return true;
}
async hit(key: string, windowSeconds: number): Promise<number> {
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 { private live(key: string): LockRecord | null {
const record = this.locks.get(key); const record = this.locks.get(key);
if (!record) return null; 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); throw new AdapterError("REDIS_UNAVAILABLE", "Coordenação indisponível. O processamento não foi iniciado.", 503);
} }
async ping(): Promise<boolean> {
return false;
}
hit(): Promise<number> {
this.fail();
}
acquireLock(): Promise<boolean> { acquireLock(): Promise<boolean> {
this.fail(); this.fail();
} }
+34 -4
View File
@@ -1,10 +1,12 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { resourceNamespace } from "@/config/env";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors"; import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { JobProgress } from "@/domain/spreadsheet-adapter/types"; import type { JobProgress } from "@/domain/spreadsheet-adapter/types";
import type { Coordinator } from "./memory"; 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 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 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 { export class RedisCoordinator implements Coordinator {
private readonly redis: Redis; private readonly redis: Redis;
@@ -15,6 +17,8 @@ export class RedisCoordinator implements Coordinator {
enableOfflineQueue: false, enableOfflineQueue: false,
connectTimeout: 2000, connectTimeout: 2000,
lazyConnect: true, lazyConnect: true,
// Separa este aplicativo dos demais do ecossistema no Redis compartilhado.
keyPrefix: "",
}); });
this.redis.on("error", () => undefined); this.redis.on("error", () => undefined);
} }
@@ -29,6 +33,19 @@ export class RedisCoordinator implements Coordinator {
} }
} }
async ping(): Promise<boolean> {
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<number> {
return this.run(async () => Number(await this.redis.eval(HIT, 1, key, String(windowSeconds))));
}
async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> { async acquireLock(key: string, owner: string, ttlSeconds: number): Promise<boolean> {
return this.run(async () => (await this.redis.set(key, owner, "EX", ttlSeconds, "NX")) === "OK"); 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<string | null> { async dequeue(): Promise<string | null> {
return this.run(async () => this.redis.rpop(queueKey())); return this.run(async () => this.redis.rpop(queueKey()));
} }
async close(): Promise<void> {
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 { export function envNamespace(): string {
return process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "local"; return resourceNamespace();
} }
export function lockKey(userId: string): string { export function lockKey(userId: string): string {
return `spreadsheet-adapter:${envNamespace()}:lock:user:${userId}`; return `${envNamespace()}:lock:user:${userId}`;
} }
export function progressKey(jobId: string): string { export function progressKey(jobId: string): string {
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`; return `${envNamespace()}:progress:${jobId}`;
} }
export function queueKey(): string { 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}`;
} }
+31 -4
View File
@@ -1,15 +1,42 @@
import path from "node:path"; import path from "node:path";
import { isDeployedEnv } from "@/config/env";
import { LocalPrivateStorage } from "./local"; import { LocalPrivateStorage } from "./local";
import { createSupabaseStorage } from "./supabase"; import { createSupabaseStorage } from "./supabase";
import type { ObjectStorage } from "./memory"; 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 { export function createStorage(): ObjectStorage {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const bucket = process.env.SUPABASE_STORAGE_BUCKET; const bucket = process.env.SUPABASE_STORAGE_BUCKET;
if (url && serviceKey && bucket) return createSupabaseStorage(url, serviceKey, bucket); const key = `${url ?? ""}|${bucket ?? ""}|${serviceKey ? "svc" : "nosvc"}`;
if (process.env.NODE_ENV === "production") { if (singleton && singletonKey === key) return singleton;
throw new Error("Armazenamento privado não configurado. A decisão de object storage continua aberta.");
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 };
+9
View File
@@ -61,4 +61,13 @@ export class LocalPrivateStorage implements ObjectStorage {
await walk(start); await walk(start);
return found.filter((key) => key.startsWith(prefix.replace(/\/$/, ""))); return found.filter((key) => key.startsWith(prefix.replace(/\/$/, "")));
} }
async healthcheck(): Promise<boolean> {
try {
await mkdir(this.root, { recursive: true });
return true;
} catch {
return false;
}
}
} }
+7
View File
@@ -5,7 +5,10 @@ export interface ObjectStorage {
put(key: string, body: Buffer): Promise<void>; put(key: string, body: Buffer): Promise<void>;
get(key: string): Promise<Buffer>; get(key: string): Promise<Buffer>;
delete(key: string): Promise<void>; delete(key: string): Promise<void>;
/** Listagem completa do prefixo. Implementações remotas devem paginar. */
list(prefix: string): Promise<string[]>; list(prefix: string): Promise<string[]>;
/** Verdadeiro quando o bucket responde. Usado pelo health check. */
healthcheck(): Promise<boolean>;
} }
export class MemoryStorage implements ObjectStorage { export class MemoryStorage implements ObjectStorage {
@@ -31,4 +34,8 @@ export class MemoryStorage implements ObjectStorage {
async list(prefix: string): Promise<string[]> { async list(prefix: string): Promise<string[]> {
return [...this.objects.keys()].filter((key) => key.startsWith(prefix)); return [...this.objects.keys()].filter((key) => key.startsWith(prefix));
} }
async healthcheck(): Promise<boolean> {
return true;
}
} }
+56 -3
View File
@@ -3,6 +3,11 @@ import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames"; import { assertStorageKey } from "@/domain/spreadsheet-adapter/filenames";
import type { ObjectStorage } from "./memory"; 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 { export function createSupabaseStorage(url: string, serviceKey: string, bucket: string): ObjectStorage {
const client: SupabaseClient = createClient(url, serviceKey, { const client: SupabaseClient = createClient(url, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
@@ -24,11 +29,59 @@ export function createSupabaseStorage(url: string, serviceKey: string, bucket: s
assertStorageKey(key); assertStorageKey(key);
await bucketApi().remove([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) { async list(prefix) {
const folder = prefix.replace(/\/$/, ""); const folder = prefix.replace(/\/$/, "");
const { data, error } = await bucketApi().list(folder, { limit: 1000 }); const found: string[] = [];
if (error || !data) return []; const pending: string[] = [folder];
return data.filter((item) => item.name).map((item) => `${folder}/${item.name}`); 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<void> {
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.`);
}
}