fix(db): desacopla configuracao administrativa das migrations
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
93f280621b
commit
8cfeeb0464
7 files changed
+342
-15
No files matched your search
+33
-8
@@ -1,20 +1,44 @@
|
|||||||
import { identityShimEnabled } from "../src/config/env";
|
import { adapterEnv, identityShimEnabled } from "../src/config/env";
|
||||||
import { migrate, pendingMigrations } from "../src/infra/db/migrate";
|
import { exitOnInvalidMigrationConfig } from "../src/config/fail-fast";
|
||||||
import { bootstrapScript, describeTarget, runWithDb } from "./_bootstrap";
|
import {
|
||||||
|
assertManifestMatches,
|
||||||
|
listMigrationFiles,
|
||||||
|
migrate,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
} from "../src/infra/db/migrate";
|
||||||
|
import { bootstrapScript, describeTarget, runScript, runWithDb } from "./_bootstrap";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aplica as migrations pendentes. `--dry-run` apenas lista: usado no pipeline
|
* Três modos, separados de propósito:
|
||||||
* antes do passo manual de migração, para que ninguém descubra o que vai rodar
|
* - `--plan` lista o manifesto no disco e não abre banco.
|
||||||
* só depois de ter rodado.
|
* - `--dry-run` lê `schema_migrations` e não aplica SQL.
|
||||||
|
* - sem flag, aplica o lote pendente numa transação.
|
||||||
*/
|
*/
|
||||||
|
const plan = process.argv.includes("--plan");
|
||||||
const dryRun = process.argv.includes("--dry-run");
|
const dryRun = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
bootstrapScript("migrate");
|
if (plan) {
|
||||||
|
runScript("migrate", async () => {
|
||||||
|
const files = await listMigrationFiles();
|
||||||
|
assertManifestMatches(files);
|
||||||
|
console.log(`plano estatico offline: ${files.join(", ")}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (adapterEnv() === "staging") exitOnInvalidMigrationConfig(process.env);
|
||||||
|
else if (adapterEnv() === "production") {
|
||||||
|
console.error(
|
||||||
|
"[adaptador-planilhas] Configuração administrativa recusada. ADAPTER_ENV: migrate administrativo de homologação exige staging",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else bootstrapScript("migrate");
|
||||||
|
|
||||||
runWithDb("migrate", "admin", async (db) => {
|
runWithDb("migrate", "admin", async (db) => {
|
||||||
|
const ledger = await readMigrationLedger(db);
|
||||||
const pending = await pendingMigrations(db);
|
const pending = await pendingMigrations(db);
|
||||||
console.log(`alvo: ${describeTarget()}`);
|
console.log(`alvo: ${adapterEnv() === "staging" ? "postgres administrativo" : describeTarget()}`);
|
||||||
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
|
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
|
||||||
|
if (ledger.state === "ausente") console.log("historico: ausente; schema_migrations nao foi criada");
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
console.log("migrations: nada pendente");
|
console.log("migrations: nada pendente");
|
||||||
return;
|
return;
|
||||||
@@ -26,3 +50,4 @@ runWithDb("migrate", "admin", async (db) => {
|
|||||||
const ran = await migrate(db);
|
const ran = await migrate(db);
|
||||||
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
|
console.log(ran.length ? `migrations aplicadas: ${ran.join(", ")}` : "migrations: nada pendente");
|
||||||
});
|
});
|
||||||
|
}
|
||||||
@@ -138,6 +138,47 @@ export function formatConfigIssues(issues: EnvIssue[]): string {
|
|||||||
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
return issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIGRATION_REF_PATTERN = /^[a-z0-9]{20}$/;
|
||||||
|
const MIGRATION_POOLER_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuração do `npm run migrate` em homologação. Não inclui web, Redis,
|
||||||
|
* Storage, Auth nem worker: esses papéis ainda não existem quando a migration
|
||||||
|
* administrativa cria o schema.
|
||||||
|
*/
|
||||||
|
export function collectMigrationAdminIssues(env: EnvMap = process.env): EnvIssue[] {
|
||||||
|
const issues: EnvIssue[] = [];
|
||||||
|
if (adapterEnv(env) !== "staging") {
|
||||||
|
issues.push({ variable: "ADAPTER_ENV", message: "migrate administrativo de homologação exige staging" });
|
||||||
|
}
|
||||||
|
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
|
||||||
|
if (!MIGRATION_REF_PATTERN.test(ref)) {
|
||||||
|
issues.push({ variable: "ADAPTER_DB_PROJECT_REF", message: "Reference ID ausente ou fora do formato" });
|
||||||
|
}
|
||||||
|
const pooler = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? "";
|
||||||
|
if (!MIGRATION_POOLER_PATTERN.test(pooler)) {
|
||||||
|
issues.push({ variable: "ADAPTER_DB_POOLER_HOST", message: "host do Session Pooler ausente ou recusado" });
|
||||||
|
}
|
||||||
|
if (!env.ADAPTER_MIGRATION_DATABASE_URL?.trim()) {
|
||||||
|
issues.push({ variable: "ADAPTER_MIGRATION_DATABASE_URL", message: "obrigatória para o migrate administrativo" });
|
||||||
|
}
|
||||||
|
const shim = env.ADAPTER_APPLY_IDENTITY_SHIM?.trim();
|
||||||
|
if (shim !== "0" && shim !== "1") {
|
||||||
|
issues.push({
|
||||||
|
variable: "ADAPTER_APPLY_IDENTITY_SHIM",
|
||||||
|
message: "decisão explícita obrigatória. 1 aplica o shim, 0 usa o schema existente",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recusa a configuração administrativa incompleta. Não abre conexão. */
|
||||||
|
export function assertMigrationAdminEnv(env: EnvMap = process.env): void {
|
||||||
|
const issues = collectMigrationAdminIssues(env);
|
||||||
|
if (issues.length === 0) return;
|
||||||
|
throw new Error(`Configuração administrativa recusada. ${formatConfigIssues(issues)}`);
|
||||||
|
}
|
||||||
|
|
||||||
function skipAssertion(env: EnvMap): boolean {
|
function skipAssertion(env: EnvMap): boolean {
|
||||||
if (env.NEXT_PHASE === "phase-production-build") return true;
|
if (env.NEXT_PHASE === "phase-production-build") return true;
|
||||||
if (env.npm_lifecycle_event === "build") return true;
|
if (env.npm_lifecycle_event === "build") return true;
|
||||||
|
|||||||
+17
-1
@@ -1,5 +1,5 @@
|
|||||||
import { buildPostgresOptions } from "@/infra/db/connection-policy";
|
import { buildPostgresOptions } from "@/infra/db/connection-policy";
|
||||||
import { assertDeployedEnv, isDeployedEnv, type EnvMap } from "./env";
|
import { assertDeployedEnv, assertMigrationAdminEnv, isDeployedEnv, type EnvMap } from "./env";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encerra o processo com código diferente de zero quando a configuração é
|
* Encerra o processo com código diferente de zero quando a configuração é
|
||||||
@@ -21,3 +21,19 @@ export function exitOnInvalidConfig(env: EnvMap = process.env): void {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barreira do `npm run migrate` em staging. Exige a URL administrativa e o
|
||||||
|
* shim explícito. A URL web, o Redis e o Storage continuam fora desta função.
|
||||||
|
*/
|
||||||
|
export function exitOnInvalidMigrationConfig(env: EnvMap = process.env): void {
|
||||||
|
try {
|
||||||
|
assertMigrationAdminEnv(env);
|
||||||
|
const adminUrl = env.ADAPTER_MIGRATION_DATABASE_URL?.trim();
|
||||||
|
if (adminUrl) buildPostgresOptions(adminUrl, "admin", env);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Configuração recusada.";
|
||||||
|
console.error(`[adaptador-planilhas] ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,4 +84,12 @@ export async function migrate(db: Db, options: MigrateOptions = {}): Promise<str
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";
|
export {
|
||||||
|
EXPECTED_MIGRATIONS,
|
||||||
|
SHIM_MIGRATIONS,
|
||||||
|
isShimMigration,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
MigrationLedgerError,
|
||||||
|
} from "./migration-manifest";
|
||||||
|
export type { MigrationLedger } from "./migration-manifest";
|
||||||
@@ -29,12 +29,46 @@ export function isShimMigration(file: string): boolean {
|
|||||||
|
|
||||||
export type PendingOptions = { applyIdentityShim?: boolean };
|
export type PendingOptions = { applyIdentityShim?: boolean };
|
||||||
|
|
||||||
|
export type MigrationLedger = { state: "ausente" } | { state: "presente"; ids: string[] };
|
||||||
|
|
||||||
|
/** A leitura do histórico falhou. Não é o mesmo que tabela ausente. */
|
||||||
|
export class MigrationLedgerError extends Error {
|
||||||
|
readonly code = "historico_indisponivel";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("historico_indisponivel");
|
||||||
|
this.name = "MigrationLedgerError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lê o histórico sem criá-lo. Tabela ausente é um estado explícito. Falha de
|
||||||
|
* permissão, conexão ou consulta interrompe: não vira lista vazia.
|
||||||
|
*/
|
||||||
|
export async function readMigrationLedger(db: Db): Promise<MigrationLedger> {
|
||||||
|
let presence: { name: string | null }[];
|
||||||
|
try {
|
||||||
|
presence = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new MigrationLedgerError();
|
||||||
|
}
|
||||||
|
if (!presence[0]?.name) return { state: "ausente" };
|
||||||
|
try {
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ id: string }>("select id from public.schema_migrations order by id"),
|
||||||
|
);
|
||||||
|
return { state: "presente", ids: rows.map((row) => row.id) };
|
||||||
|
} catch {
|
||||||
|
throw new MigrationLedgerError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Migrations esperadas que ainda não constam em `schema_migrations`. */
|
/** Migrations esperadas que ainda não constam em `schema_migrations`. */
|
||||||
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
|
export async function pendingMigrations(db: Db, options: PendingOptions = {}): Promise<string[]> {
|
||||||
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
const applyShim = options.applyIdentityShim ?? identityShimEnabled();
|
||||||
const appliedRows = await db
|
const ledger = await readMigrationLedger(db);
|
||||||
.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations"))
|
const applied = new Set(ledger.state === "presente" ? ledger.ids : []);
|
||||||
.catch(() => [] as { id: string }[]);
|
|
||||||
const applied = new Set(appliedRows.map((row) => row.id));
|
|
||||||
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
|
return EXPECTED_MIGRATIONS.filter((file) => !applied.has(file) && (applyShim || !isShimMigration(file)));
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { createPostgresDb } from "@/infra/db/postgres";
|
import { createPostgresDb } from "@/infra/db/postgres";
|
||||||
import { migrate, pendingMigrations } from "@/infra/db/migrate";
|
import { migrate, pendingMigrations, readMigrationLedger, MigrationLedgerError } from "@/infra/db/migrate";
|
||||||
import type { Db } from "@/infra/db/types";
|
import type { Db } from "@/infra/db/types";
|
||||||
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
||||||
|
|
||||||
@@ -12,6 +12,13 @@ import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
|||||||
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
const url = process.env.ADAPTER_TEST_DATABASE_URL;
|
||||||
const enabled = Boolean(url);
|
const enabled = Boolean(url);
|
||||||
|
|
||||||
|
function roleUrl(base: string, role: string): string {
|
||||||
|
const parsed = new URL(base);
|
||||||
|
parsed.username = role;
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
const ALPHA = "11111111-1111-4111-8111-111111111111";
|
||||||
const BETA = "22222222-2222-4222-8222-222222222222";
|
const BETA = "22222222-2222-4222-8222-222222222222";
|
||||||
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
const ANA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
@@ -161,6 +168,32 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
|
|||||||
expect(queued[0]?.current_user).toBe("app_user");
|
expect(queued[0]?.current_user).toBe("app_user");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("separa histórico ausente, íntegro e ilegível", async () => {
|
||||||
|
await db.exec("drop table public.schema_migrations");
|
||||||
|
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
|
||||||
|
const aindaAusente = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
expect(aindaAusente[0]?.name ?? null).toBeNull();
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const presente = await readMigrationLedger(db);
|
||||||
|
expect(presente.state).toBe("presente");
|
||||||
|
if (presente.state === "presente") expect(presente.ids).toHaveLength(5);
|
||||||
|
|
||||||
|
await db.exec(`
|
||||||
|
do $$ begin
|
||||||
|
create role historico_leitor nologin nosuperuser nobypassrls;
|
||||||
|
exception when duplicate_object then null;
|
||||||
|
end $$;
|
||||||
|
grant usage on schema public to historico_leitor;
|
||||||
|
alter role historico_leitor login;
|
||||||
|
`);
|
||||||
|
const leitor = createPostgresDb(roleUrl(url as string, "historico_leitor"));
|
||||||
|
await expect(readMigrationLedger(leitor)).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
await leitor.close();
|
||||||
|
await db.exec("revoke all on schema public from historico_leitor; drop role historico_leitor");
|
||||||
|
});
|
||||||
|
|
||||||
it("pula o shim quando a decisão é não aplicar", async () => {
|
it("pula o shim quando a decisão é não aplicar", async () => {
|
||||||
const pending = await pendingMigrations(db, { applyIdentityShim: false });
|
const pending = await pendingMigrations(db, { applyIdentityShim: false });
|
||||||
expect(pending).not.toContain("0001_platform_shim.sql");
|
expect(pending).not.toContain("0001_platform_shim.sql");
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertDeployedEnv, assertMigrationAdminEnv, collectMigrationAdminIssues } from "@/config/env";
|
||||||
|
import { DatabaseGuardError, buildPostgresOptions } from "@/infra/db/connection-policy";
|
||||||
|
import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca";
|
||||||
|
import { evaluateProjectIdentity } from "@/infra/db/target-guard";
|
||||||
|
import { AUTHORIZED_HML_PROJECT } from "@/infra/db/connection-policy";
|
||||||
|
import {
|
||||||
|
MigrationLedgerError,
|
||||||
|
pendingMigrations,
|
||||||
|
readMigrationLedger,
|
||||||
|
} from "@/infra/db/migration-manifest";
|
||||||
|
import { createMemoryDb } from "@/infra/db/pglite";
|
||||||
|
import { migrate } from "@/infra/db/migrate";
|
||||||
|
import type { Db, Query } from "@/infra/db/types";
|
||||||
|
|
||||||
|
const REF = "abcd1234abcd1234wxyz";
|
||||||
|
const OUTRO = "zzzzzzzzzzzzzzzzzzz1";
|
||||||
|
const HOST = "aws-1-us-west-2.pooler.supabase.com";
|
||||||
|
|
||||||
|
function adminUrl(user = `postgres.${REF}`, database = "postgres", host = HOST, sslmode = "verify-full"): string {
|
||||||
|
return `postgres://${user}:segredo@${host}:5432/${database}?sslmode=${sslmode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEnv = {
|
||||||
|
ADAPTER_ENV: "staging",
|
||||||
|
ADAPTER_DB_PROJECT_REF: REF,
|
||||||
|
ADAPTER_DB_POOLER_HOST: HOST,
|
||||||
|
ADAPTER_MIGRATION_DATABASE_URL: adminUrl(),
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
function banco(query: Query["query"]): Db {
|
||||||
|
return {
|
||||||
|
exec: async () => undefined,
|
||||||
|
withUser: async (_userId, fn) => fn({ query }),
|
||||||
|
withService: async (fn) => fn({ query }),
|
||||||
|
withTransaction: async (fn) => fn({ exec: async () => undefined, query }),
|
||||||
|
close: async () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrate administrativo", () => {
|
||||||
|
it("aceita staging só com a configuração administrativa", () => {
|
||||||
|
expect(collectMigrationAdminIssues(adminEnv)).toEqual([]);
|
||||||
|
expect(() => assertMigrationAdminEnv(adminEnv)).not.toThrow();
|
||||||
|
const options = buildPostgresOptions(adminEnv.ADAPTER_MIGRATION_DATABASE_URL, "admin", adminEnv);
|
||||||
|
expect(options.ssl?.rejectUnauthorized).toBe(true);
|
||||||
|
expect(options.ssl?.ca).toBe(SUPABASE_ROOT_CA_2021);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não exige web, Redis, Storage, login nem TTL", () => {
|
||||||
|
const env = { ...adminEnv };
|
||||||
|
expect(env).not.toHaveProperty("DATABASE_URL");
|
||||||
|
expect(env).not.toHaveProperty("REDIS_URL");
|
||||||
|
expect(env).not.toHaveProperty("SUPABASE_STORAGE_BUCKET");
|
||||||
|
expect(env).not.toHaveProperty("ADAPTER_STAGING_AUTH");
|
||||||
|
expect(env).not.toHaveProperty("SPREADSHEET_TEMP_TTL_SECONDS");
|
||||||
|
expect(() => assertMigrationAdminEnv(env)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa Reference ID ausente e divergente", () => {
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_PROJECT_REF: "" }).map((issue) => issue.variable)).toContain(
|
||||||
|
"ADAPTER_DB_PROJECT_REF",
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${OUTRO}`), "admin", adminEnv)).toThrow(DatabaseGuardError);
|
||||||
|
expect(() =>
|
||||||
|
evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: OUTRO }, adminEnv),
|
||||||
|
).toThrow(/marcador_diverge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa host, banco, usuário e TLS fora do destino", () => {
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_DB_POOLER_HOST: "db.exemplo.supabase.co" }).map((item) => item.variable)).toContain(
|
||||||
|
"ADAPTER_DB_POOLER_HOST",
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", "aws-9-us-west-2.pooler.supabase.com"), "admin", adminEnv)).toThrow(
|
||||||
|
/destino_incompativel/,
|
||||||
|
);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "template1"), "admin", adminEnv)).toThrow(/destino_incompativel/);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`adaptador_web.${REF}`), "admin", adminEnv)).toThrow(/usuario_url_diverge/);
|
||||||
|
expect(() => buildPostgresOptions(adminUrl(`postgres.${REF}`, "postgres", HOST, "require"), "admin", adminEnv)).toThrow(/tls_inseguro/);
|
||||||
|
expect(() =>
|
||||||
|
buildPostgresOptions(
|
||||||
|
`postgres://postgres.${REF}:segredo@supabase.elopro.app:5432/postgres?sslmode=verify-full`,
|
||||||
|
"admin",
|
||||||
|
{ ...adminEnv, ADAPTER_DB_POOLER_HOST: "supabase.elopro.app" },
|
||||||
|
),
|
||||||
|
).toThrow(/host_elopro_preservado|pooler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa shim ausente e ambiente que não é staging", () => {
|
||||||
|
const semShim = { ...adminEnv } as Record<string, string | undefined>;
|
||||||
|
delete semShim.ADAPTER_APPLY_IDENTITY_SHIM;
|
||||||
|
expect(collectMigrationAdminIssues(semShim).map((issue) => issue.variable)).toContain("ADAPTER_APPLY_IDENTITY_SHIM");
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_APPLY_IDENTITY_SHIM: "true" }).map((issue) => issue.variable)).toContain(
|
||||||
|
"ADAPTER_APPLY_IDENTITY_SHIM",
|
||||||
|
);
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "production" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
|
||||||
|
expect(collectMigrationAdminIssues({ ...adminEnv, ADAPTER_ENV: "development" }).map((issue) => issue.variable)).toContain("ADAPTER_ENV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mantém a aplicação e o worker sujeitos à configuração operacional", () => {
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/DATABASE_URL/);
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1" })).toThrow(/REDIS_URL/);
|
||||||
|
expect(() => assertDeployedEnv({ ADAPTER_ENV: "staging", ADAPTER_APPLY_IDENTITY_SHIM: "1", DATABASE_URL: adminUrl(), REDIS_URL: "redis://host" })).toThrow(
|
||||||
|
/SUPABASE_STORAGE_BUCKET/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
assertDeployedEnv({
|
||||||
|
ADAPTER_ENV: "staging",
|
||||||
|
ADAPTER_APPLY_IDENTITY_SHIM: "1",
|
||||||
|
DATABASE_URL: adminUrl(),
|
||||||
|
REDIS_URL: "redis://host",
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: "https://exemplo.supabase.co",
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: "service",
|
||||||
|
SUPABASE_STORAGE_BUCKET: "privado",
|
||||||
|
SPREADSHEET_TEMP_TTL_SECONDS: "3600",
|
||||||
|
}),
|
||||||
|
).toThrow(/ADAPTER_STAGING_AUTH/);
|
||||||
|
const worker = readFileSync(path.join(process.cwd(), "scripts", "worker.ts"), "utf8");
|
||||||
|
const migrateScript = readFileSync(path.join(process.cwd(), "scripts", "migrate.ts"), "utf8");
|
||||||
|
expect(worker).toContain('bootstrapScript("worker")');
|
||||||
|
expect(worker).not.toContain("exitOnInvalidMigrationConfig");
|
||||||
|
expect(migrateScript).toContain("exitOnInvalidMigrationConfig");
|
||||||
|
expect(migrateScript).toContain('process.argv.includes("--plan")');
|
||||||
|
expect(migrateScript).toContain('process.argv.includes("--dry-run")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("histórico do dry-run", () => {
|
||||||
|
it("não transforma falha de leitura em histórico vazio", async () => {
|
||||||
|
const db = banco(async (sql) => {
|
||||||
|
if (sql.includes("to_regclass")) return [{ name: "schema_migrations" }];
|
||||||
|
throw Object.assign(new Error("permission denied"), { code: "42501" });
|
||||||
|
});
|
||||||
|
await expect(readMigrationLedger(db)).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).rejects.toBeInstanceOf(MigrationLedgerError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifica histórico ausente sem criar a tabela", async () => {
|
||||||
|
const db = await createMemoryDb();
|
||||||
|
await expect(readMigrationLedger(db)).resolves.toEqual({ state: "ausente" });
|
||||||
|
const ainda = await db.withService((query) =>
|
||||||
|
query.query<{ name: string | null }>("select to_regclass('public.schema_migrations')::text as name"),
|
||||||
|
);
|
||||||
|
expect(ainda[0]?.name ?? null).toBeNull();
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toHaveLength(5);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lê o histórico íntegro depois da aplicação local", async () => {
|
||||||
|
const db = await createMemoryDb();
|
||||||
|
await migrate(db, { applyIdentityShim: true });
|
||||||
|
const ledger = await readMigrationLedger(db);
|
||||||
|
expect(ledger).toEqual({
|
||||||
|
state: "presente",
|
||||||
|
ids: [
|
||||||
|
"0001_platform_shim.sql",
|
||||||
|
"0002_spreadsheet_adapter.sql",
|
||||||
|
"0003_rls.sql",
|
||||||
|
"0004_audit_append_only.sql",
|
||||||
|
"0005_maintenance_roles.sql",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(pendingMigrations(db, { applyIdentityShim: true })).resolves.toEqual([]);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user