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:
1 parent
419325db12
commit
b972589949
13 files changed
+474
-43
No files matched your search
+143
-18
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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";
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user