Files
agenciapsilmno/database-novo/db.cjs
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

1065 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Database CLI
// =============================================================================
// Uso: node db.cjs <comando> [opcoes]
//
// Comandos:
// setup Instalação do zero (schema + fixes + seeds + migrations)
// backup Exporta backup com data atual (+ supabase_restore.sql)
// restore [data] Restaura de um backup (ex: 2026-04-17)
// migrate Aplica migrations pendentes
// seed [grupo] Roda seeds (all|users|system|test_data)
// status Mostra estado atual do banco
// diff Compara schema atual vs último backup
// reset Reseta o banco e reinstala tudo
// verify Verifica integridade dos dados essenciais
// schema-export Exporta schema granular para schema/00_full..10_grants
// dashboard Gera dashboard HTML interativo (tabelas + infra)
// help Mostra ajuda
// =============================================================================
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const ROOT = __dirname;
const CONFIG = JSON.parse(fs.readFileSync(path.join(ROOT, 'db.config.json'), 'utf8'));
const CONTAINER = CONFIG.container;
const DB = CONFIG.database;
const USER = CONFIG.user;
const MIGRATIONS_DIR = path.resolve(ROOT, CONFIG.migrationsDir || 'migrations');
const SEEDS_DIR = path.resolve(ROOT, CONFIG.seedsDir || 'seeds');
const FIXES_DIR = path.resolve(ROOT, CONFIG.fixesDir || 'fixes');
// ---------------------------------------------------------------------------
// Colors (sem dependências externas)
// ---------------------------------------------------------------------------
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
};
function log(msg) { console.log(msg); }
function info(msg) { log(`${c.cyan}${c.reset} ${msg}`); }
function ok(msg) { log(`${c.green}${c.reset} ${msg}`); }
function warn(msg) { log(`${c.yellow}${c.reset} ${msg}`); }
function err(msg) { log(`${c.red}${c.reset} ${msg}`); }
function title(msg){ log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`); }
function step(msg) { log(`${c.gray}${c.reset} ${msg}`); }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function dockerRunning() {
try {
const result = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], {
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe']
});
return result.stdout.trim() === 'true';
} catch {
return false;
}
}
function psql(sql, opts = {}) {
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} ${opts.tuples ? '-t' : ''} ${opts.quiet ? '-q' : ''} -c "${sql.replace(/"/g, '\\"')}"`;
return execSync(cmd, {
encoding: 'utf8',
timeout: 30000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
}).trim();
}
function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
return execSync(cmd, {
input: utf8Content,
encoding: 'utf8',
timeout: 120000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
});
}
function pgDump(args) {
const cmd = `docker exec -e PGCLIENTENCODING=UTF8 ${CONTAINER} pg_dump -U ${USER} -d ${DB} ${args}`;
return execSync(cmd, { encoding: 'utf8', timeout: 180000, maxBuffer: 100 * 1024 * 1024 });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function fileHash(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function requireDocker() {
if (!dockerRunning()) {
err(`Container "${CONTAINER}" não está rodando.`);
log(`\n Inicie o Supabase primeiro:`);
log(` ${c.cyan}npx supabase start${c.reset}\n`);
process.exit(1);
}
}
function listBackups() {
const dir = path.join(ROOT, 'backups');
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
}
// ---------------------------------------------------------------------------
// Migration tracking table
// ---------------------------------------------------------------------------
function ensureMigrationTable() {
psql(`
CREATE TABLE IF NOT EXISTS _db_migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'migration',
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
}
function getAppliedMigrations() {
ensureMigrationTable();
const result = psql('SELECT filename, hash, category, applied_at::text FROM _db_migrations ORDER BY id;', { tuples: true });
if (!result) return [];
return result
.split('\n')
.filter(Boolean)
.map((line) => {
const [filename, hash, category, applied_at] = line.split('|').map((s) => s.trim());
return { filename, hash, category, applied_at };
});
}
function recordMigration(filename, hash, category) {
psql(`INSERT INTO _db_migrations (filename, hash, category) VALUES ('${filename}', '${hash}', '${category}') ON CONFLICT (filename) DO UPDATE SET hash = EXCLUDED.hash, applied_at = now();`);
}
// ---------------------------------------------------------------------------
// Supabase-compatible dump builder
// ---------------------------------------------------------------------------
function buildSupabaseDump(backupDir) {
step('Gerando dump compatível com Supabase (supabase_restore.sql)...');
const parts = [];
parts.push('-- ==========================================================');
parts.push('-- AgenciaPsi — Supabase-compatible full restore dump');
parts.push(`-- Gerado em: ${new Date().toISOString()}`);
parts.push('-- ');
parts.push('-- USO: Para restaurar este dump em um Supabase limpo:');
parts.push('-- 1. npx supabase db reset (limpa tudo)');
parts.push('-- 2. Rode este arquivo:');
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB} \\`);
parts.push('-- < backups/YYYY-MM-DD/supabase_restore.sql');
parts.push('--');
parts.push('-- OU via pipe:');
parts.push('-- cat backups/YYYY-MM-DD/supabase_restore.sql | \\');
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}`);
parts.push('-- ==========================================================');
parts.push('');
parts.push("SET client_encoding TO 'UTF8';");
parts.push('SET statement_timeout = 0;');
parts.push('SET lock_timeout = 0;');
parts.push("SET standard_conforming_strings = on;");
parts.push('');
// 1. Schema public: drop + recreate
parts.push('-- [1/5] Limpar schema public');
parts.push('DROP SCHEMA IF EXISTS public CASCADE;');
parts.push('CREATE SCHEMA public;');
parts.push('GRANT ALL ON SCHEMA public TO postgres;');
parts.push('GRANT ALL ON SCHEMA public TO public;');
parts.push('');
// 2. Schema-only dump (public schema only, no infra)
parts.push('-- [2/5] Estrutura (tabelas, índices, constraints, triggers, functions)');
const schemaDump = pgDump('--schema-only --no-owner --no-privileges --schema=public');
parts.push(schemaDump);
parts.push('');
// 3. Auth users data (crucial for Supabase)
parts.push('-- [3/5] Dados auth.users (essencial para autenticação)');
try {
const authData = pgDump('--data-only --no-owner --no-privileges --table=auth.users --table=auth.identities --table=auth.sessions --table=auth.refresh_tokens --table=auth.mfa_factors --table=auth.mfa_challenges');
parts.push(authData);
} catch {
parts.push('-- AVISO: Não foi possível exportar dados de auth (pode estar vazio)');
}
parts.push('');
// 4. All public data
parts.push('-- [4/5] Dados das tabelas public');
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics',
'supabase_migrations', 'auth'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
const publicData = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
parts.push(publicData);
parts.push('');
// 5. Storage buckets/objects metadata (if any)
parts.push('-- [5/5] Storage buckets (metadados)');
try {
const storageBuckets = pgDump('--data-only --no-owner --no-privileges --table=storage.buckets');
parts.push(storageBuckets);
} catch {
parts.push('-- AVISO: Nenhum bucket de storage encontrado');
}
parts.push('');
parts.push('-- Restore finalizado.');
const dumpContent = parts.join('\n');
const dumpPath = path.join(backupDir, 'supabase_restore.sql');
fs.writeFileSync(dumpPath, dumpContent);
return dumpPath;
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
const commands = {};
// ---- SETUP ----
commands.setup = function () {
title('Setup — Instalação do zero');
requireDocker();
// 1. Schema base
const schemaFile = path.join(ROOT, CONFIG.schema);
if (fs.existsSync(schemaFile)) {
info('Aplicando schema base...');
psqlFile(schemaFile);
ok('Schema aplicado');
} else {
warn(`Schema não encontrado: ${schemaFile}`);
warn('Rode "node db.cjs schema-export" depois de uma migração fresh para gerar o schema.');
}
// 2. Fixes (aplicados antes dos seeds para corrigir o schema)
if (Array.isArray(CONFIG.fixes) && CONFIG.fixes.length > 0) {
info('Aplicando fixes...');
let fixCount = 0;
for (const fix of CONFIG.fixes) {
const fixPath = path.join(FIXES_DIR, fix);
if (fs.existsSync(fixPath)) {
step(fix);
psqlFile(fixPath);
fixCount++;
}
}
ok(`${fixCount} fix(es) aplicado(s)`);
}
// 3. Seeds (users + system)
commands.seed('all');
// 4. Migration tracking table
ensureMigrationTable();
// 5. Record seeds + fixes como aplicados
const allSeeds = [...(CONFIG.seeds?.users || []), ...(CONFIG.seeds?.system || [])];
for (const seed of allSeeds) {
const seedPath = path.join(SEEDS_DIR, seed);
if (fs.existsSync(seedPath)) {
recordMigration(seed, fileHash(seedPath), 'seed');
}
}
for (const fix of (CONFIG.fixes || [])) {
const fixPath = path.join(FIXES_DIR, fix);
if (fs.existsSync(fixPath)) {
recordMigration(fix, fileHash(fixPath), 'fix');
}
}
// 6. Aplicar migrations incrementais (opcional - só se tiver pendentes)
if (fs.existsSync(MIGRATIONS_DIR)) {
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
if (files.length > 0) {
info(`Aplicando ${files.length} migration(s)...`);
for (const file of files) {
step(file);
try {
psqlFile(path.join(MIGRATIONS_DIR, file));
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
} catch (e) {
warn(` ${file} falhou (pode já ter sido aplicada via schema/seeds): ${e.message.split('\n')[0]}`);
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
}
}
ok(`${files.length} migration(s) aplicada(s)`);
}
}
ok('Setup completo!');
log('');
// 7. Auto-backup
info('Criando backup pós-setup...');
commands.backup();
// 8. Verify
commands.verify();
};
// ---- BACKUP ----
commands.backup = function () {
title('Backup');
requireDocker();
const date = today();
const dir = path.join(ROOT, 'backups', date);
ensureDir(dir);
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics', 'supabase_migrations'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
step('Exportando schema...');
const schema = pgDump('--schema-only --no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'schema.sql'), schema);
step('Exportando dados...');
const data = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
fs.writeFileSync(path.join(dir, 'data.sql'), data);
step('Exportando dump completo...');
const full = pgDump('--no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'full_dump.sql'), full);
// Dump compatível com Supabase (restauração completa)
buildSupabaseDump(dir);
const files = ['schema.sql', 'data.sql', 'full_dump.sql', 'supabase_restore.sql'];
const sizes = files.map((f) => {
const fPath = path.join(dir, f);
if (!fs.existsSync(fPath)) return null;
const stat = fs.statSync(fPath);
return `${f}: ${(stat.size / 1024).toFixed(0)}KB`;
}).filter(Boolean);
ok(`Backup salvo em backups/${date}/`);
sizes.forEach((s) => step(s));
cleanupBackups();
};
function cleanupBackups() {
const backups = listBackups();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - CONFIG.backupRetentionDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
let removed = 0;
for (const b of backups) {
if (b < cutoffStr) {
const bDir = path.join(ROOT, 'backups', b);
fs.rmSync(bDir, { recursive: true, force: true });
removed++;
}
}
if (removed > 0) {
info(`${removed} backup(s) antigo(s) removido(s) (>${CONFIG.backupRetentionDays} dias)`);
}
}
// ---- RESTORE ----
commands.restore = function (dateArg) {
title('Restore');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup encontrado.');
process.exit(1);
}
const date = dateArg || backups[0];
const dir = path.join(ROOT, 'backups', date);
if (!fs.existsSync(dir)) {
err(`Backup não encontrado: ${date}`);
log(`\n Backups disponíveis:`);
backups.forEach((b) => log(` ${c.cyan}${b}${c.reset}`));
process.exit(1);
}
// Safety backup before restore
info('Criando backup de segurança antes do restore...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup de segurança (banco pode estar vazio)');
}
const supaRestore = path.join(dir, 'supabase_restore.sql');
const fullDump = path.join(dir, 'full_dump.sql');
const schemaFile = path.join(dir, 'schema.sql');
const dataFile = path.join(dir, 'data.sql');
if (fs.existsSync(supaRestore)) {
info(`Restaurando de backups/${date}/supabase_restore.sql (Supabase-compatible)...`);
psqlFile(supaRestore);
} else if (fs.existsSync(fullDump)) {
info(`Restaurando de backups/${date}/full_dump.sql ...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando full dump...');
psqlFile(fullDump);
} else if (fs.existsSync(schemaFile) && fs.existsSync(dataFile)) {
info(`Restaurando de backups/${date}/ (schema + data)...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando schema...');
psqlFile(schemaFile);
step('Aplicando dados...');
psqlFile(dataFile);
} else {
err(`Backup incompleto em ${date}/`);
process.exit(1);
}
ok(`Banco restaurado de backups/${date}/`);
commands.verify();
};
// ---- MIGRATE ----
commands.migrate = function () {
title('Migrate');
requireDocker();
ensureMigrationTable();
if (!fs.existsSync(MIGRATIONS_DIR)) {
info('Nenhuma pasta migrations/ encontrada.');
return;
}
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
if (files.length === 0) {
info('Nenhuma migration encontrada.');
return;
}
const applied = getAppliedMigrations().map((m) => m.filename);
const pending = files.filter((f) => !applied.includes(f));
if (pending.length === 0) {
ok('Todas as migrations já foram aplicadas.');
return;
}
// Auto-backup before migrating
info('Criando backup antes de migrar...');
commands.backup();
info(`${pending.length} migration(s) pendente(s):`);
for (const file of pending) {
step(`Aplicando ${file}...`);
const filePath = path.join(MIGRATIONS_DIR, file);
try {
psqlFile(filePath);
recordMigration(file, fileHash(filePath), 'migration');
ok(` ${file}`);
} catch (e) {
err(` FALHA em ${file}: ${e.message}`);
err('Migration abortada. Banco pode estar em estado parcial.');
err('Use "node db.cjs restore" para voltar ao backup.');
process.exit(1);
}
}
ok(`${pending.length} migration(s) aplicada(s)`);
};
// ---- SEED ----
commands.seed = function (group) {
const validGroups = ['all', 'users', 'system', 'test_data'];
if (!group) group = 'all';
if (!validGroups.includes(group)) {
err(`Grupo inválido: ${group}`);
log(` Grupos válidos: ${validGroups.join(', ')}`);
process.exit(1);
}
title(`Seeds — ${group}`);
requireDocker();
const groups = group === 'all' ? ['users', 'system'] : [group];
let total = 0;
for (const g of groups) {
const seeds = CONFIG.seeds?.[g];
if (!seeds || seeds.length === 0) continue;
info(`Grupo: ${g}`);
for (const seed of seeds) {
const seedPath = path.join(SEEDS_DIR, seed);
if (!fs.existsSync(seedPath)) {
warn(` Arquivo não encontrado: ${seed}`);
continue;
}
step(seed);
try {
psqlFile(seedPath);
total++;
} catch (e) {
err(` FALHA em ${seed}: ${e.stderr || e.message}`);
process.exit(1);
}
}
}
ok(`${total} seed(s) aplicado(s)`);
};
// ---- STATUS ----
commands.status = function () {
title('Status');
requireDocker();
ok(`Container: ${CONTAINER} (rodando)`);
const backups = listBackups();
if (backups.length > 0) {
ok(`Último backup: ${backups[0]}`);
info(`Total de backups: ${backups.length}`);
} else {
warn('Nenhum backup encontrado');
}
try {
const applied = getAppliedMigrations();
if (applied.length > 0) {
info(`Registros em _db_migrations: ${applied.length}`);
applied.slice(-5).forEach((m) => {
step(`${m.filename} ${c.gray}(${m.category}, ${m.applied_at})${c.reset}`);
});
}
if (fs.existsSync(MIGRATIONS_DIR)) {
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
const pending = files.filter((f) => !applied.map((m) => m.filename).includes(f));
if (pending.length > 0) {
warn(`${pending.length} migration(s) pendente(s):`);
pending.forEach((f) => step(`${c.yellow}${f}${c.reset}`));
}
}
} catch {
info('Tabela _db_migrations não existe (rode setup primeiro)');
}
log('');
info('Dados no banco:');
const statusTables = CONFIG.status?.tables || [];
for (const table of statusTables) {
try {
const count = psql(`SELECT count(*) FROM ${table}`, { tuples: true }).trim();
const color = parseInt(count) > 0 ? c.green : c.red;
step(`${table}: ${color}${count}${c.reset}`);
} catch {
step(`${table}: ${c.gray}(tabela não existe)${c.reset}`);
}
}
};
// ---- DIFF ----
commands.diff = function () {
title('Diff — Schema');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup para comparar. Rode "node db.cjs backup" primeiro.');
process.exit(1);
}
const lastBackup = backups[0];
const lastSchemaPath = path.join(ROOT, 'backups', lastBackup, 'schema.sql');
if (!fs.existsSync(lastSchemaPath)) {
err(`Schema não encontrado no backup ${lastBackup}`);
process.exit(1);
}
info('Exportando schema atual...');
const currentSchema = pgDump('--schema-only --no-owner --no-privileges');
const lastSchema = fs.readFileSync(lastSchemaPath, 'utf8');
const extractTables = (sql) => {
const tables = {};
const regex = /CREATE TABLE (?:IF NOT EXISTS )?(\S+)\s*\(([\s\S]*?)\);/g;
let match;
while ((match = regex.exec(sql)) !== null) {
tables[match[1]] = match[2].trim();
}
return tables;
};
const currentTables = extractTables(currentSchema);
const lastTables = extractTables(lastSchema);
const allTables = new Set([...Object.keys(currentTables), ...Object.keys(lastTables)]);
let added = 0, removed = 0, changed = 0, unchanged = 0;
for (const table of [...allTables].sort()) {
if (!lastTables[table]) {
log(` ${c.green}+ ${table}${c.reset} (nova)`);
added++;
} else if (!currentTables[table]) {
log(` ${c.red}- ${table}${c.reset} (removida)`);
removed++;
} else if (currentTables[table] !== lastTables[table]) {
log(` ${c.yellow}~ ${table}${c.reset} (alterada)`);
changed++;
} else {
unchanged++;
}
}
log('');
ok(`Comparado com backup de ${lastBackup}:`);
step(`${added} nova(s), ${changed} alterada(s), ${removed} removida(s), ${unchanged} sem mudança`);
};
// ---- RESET ----
commands.reset = function () {
title('Reset — CUIDADO');
requireDocker();
info('Criando backup antes do reset...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup');
}
warn('Resetando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
ok('Schema public resetado');
commands.setup();
};
// ---- VERIFY ----
commands.verify = function () {
title('Verificação de integridade');
requireDocker();
let pass = 0, fail = 0;
const verify = CONFIG.verify || { tables: [], views: [] };
for (const check of (verify.tables || [])) {
try {
const count = parseInt(psql(`SELECT count(*) FROM ${check.name}`, { tuples: true }).trim());
if (count >= check.min) {
ok(`${check.name}: ${count} (mín: ${check.min})`);
pass++;
} else {
err(`${check.name}: ${count} (esperado ≥ ${check.min})`);
fail++;
}
} catch {
err(`${check.name}: tabela não existe`);
fail++;
}
}
for (const view of (verify.views || [])) {
try {
const cnt = psql(`SELECT count(*) FROM ${view};`, { tuples: true }).trim();
ok(`${view}: ${cnt} registros`);
pass++;
} catch {
err(`${view}: view não existe`);
fail++;
}
}
log('');
if (fail === 0) {
ok(`${c.bold}Todos os ${pass} checks passaram!${c.reset}`);
} else {
err(`${fail} check(s) falharam, ${pass} passaram`);
}
};
// ---- SCHEMA-EXPORT ----
commands['schema-export'] = function () {
title('Schema Export');
requireDocker();
const schemaDir = path.join(ROOT, 'schema');
const dirs = {
full: path.join(schemaDir, '00_full'),
extensions: path.join(schemaDir, '01_extensions'),
types: path.join(schemaDir, '02_types'),
functions: path.join(schemaDir, '03_functions'),
tables: path.join(schemaDir, '04_tables'),
views: path.join(schemaDir, '05_views'),
indexes: path.join(schemaDir, '06_indexes'),
foreignKeys: path.join(schemaDir, '07_foreign_keys'),
triggers: path.join(schemaDir, '08_triggers'),
policies: path.join(schemaDir, '09_policies'),
grants: path.join(schemaDir, '10_grants'),
};
// Limpa diretórios antes para remover arquivos stale de exports anteriores
for (const dir of Object.values(dirs)) {
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.sql')) fs.rmSync(path.join(dir, f), { force: true });
}
}
ensureDir(dir);
}
// 00_full — dump completo
step('00_full/schema.sql (dump completo)...');
const fullSchema = pgDump('--schema-only --no-owner --no-privileges');
fs.writeFileSync(path.join(dirs.full, 'schema.sql'), fullSchema);
// 01_extensions
step('01_extensions...');
const extSql = `SELECT 'CREATE EXTENSION IF NOT EXISTS ' || quote_ident(extname) || ' WITH SCHEMA ' || quote_ident(nspname) || ';' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace = n.oid WHERE extname NOT IN ('plpgsql') ORDER BY extname;`;
const extResult = psql(extSql, { tuples: true });
const extLines = extResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (extLines.length > 0) {
const content = `-- Extensions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${extLines.length}\n\n` + extLines.join('\n') + '\n';
fs.writeFileSync(path.join(dirs.extensions, 'extensions.sql'), content);
}
// 02_types — enums públicos
step('02_types...');
const typesSql = `
SELECT pg_catalog.format_type(t.oid, NULL) || ' AS ENUM (' ||
string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) || ');'
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE n.nspname = 'public'
GROUP BY t.oid, t.typname;`;
const typesResult = psql(typesSql, { tuples: true });
const typesLines = typesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (typesLines.length > 0) {
const typesContent = `-- Public Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
typesLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.types, 'public_types.sql'), typesContent);
}
try {
const authTypesSql = typesSql.replace("'public'", "'auth'");
const authTypesResult = psql(authTypesSql, { tuples: true });
const authLines = authTypesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (authLines.length > 0) {
const authContent = `-- Auth Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
authLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.types, 'auth_types.sql'), authContent);
}
} catch { /* auth types may not exist */ }
// 03_functions
step('03_functions...');
const funcBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+[\s\S]*?(?:\$\$[^$]*\$\$|\$[a-zA-Z_]+\$[\s\S]*?\$[a-zA-Z_]+\$)\s*(?:;|$)/gi) || [];
const funcsBySchema = {};
for (const block of funcBlocks) {
const nameMatch = block.match(/FUNCTION\s+([\w.]+)\./);
const schema = nameMatch ? nameMatch[1] : 'public';
if (!funcsBySchema[schema]) funcsBySchema[schema] = [];
funcsBySchema[schema].push(block);
}
for (const [schema, funcs] of Object.entries(funcsBySchema)) {
const content = `-- Functions: ${schema}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcs.length}\n\n` +
funcs.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.functions, `${schema}.sql`), content);
}
if (funcBlocks.length > 0) {
const allFuncs = `-- All Functions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcBlocks.length}\n\n` +
funcBlocks.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.functions, '_all.sql'), allFuncs);
}
// 04_tables — agrupado por domínio
step('04_tables...');
const tableBlocks = fullSchema.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.\S+\s*\([\s\S]*?\);/gi) || [];
const domainTables = CONFIG.domains || {};
const tableToDomain = {};
for (const [domain, tables] of Object.entries(domainTables)) {
for (const t of tables) tableToDomain[t] = domain;
}
const tablesByDomain = {};
for (const block of tableBlocks) {
const nameMatch = block.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.(\S+)/i);
if (!nameMatch) continue;
const name = nameMatch[1];
const domain = tableToDomain[name] || 'outros';
if (!tablesByDomain[domain]) tablesByDomain[domain] = [];
tablesByDomain[domain].push(block);
}
for (const [domain, blocks] of Object.entries(tablesByDomain)) {
const content = `-- Tables: ${domain}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${blocks.length}\n\n` +
blocks.join('\n\n') + '\n';
const filename = domain.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + '.sql';
fs.writeFileSync(path.join(dirs.tables, filename), content);
}
// 05_views
step('05_views...');
const viewBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+public\.[\s\S]*?;/gi) || [];
if (viewBlocks.length > 0) {
const content = `-- Views\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${viewBlocks.length}\n\n` +
viewBlocks.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.views, 'views.sql'), content);
}
// 06_indexes
step('06_indexes...');
const indexLines = fullSchema.match(/CREATE (?:UNIQUE )?INDEX\s+\S+\s+ON\s+public\.\S+[\s\S]*?;/gi) || [];
if (indexLines.length > 0) {
const content = `-- Indexes\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${indexLines.length}\n\n` +
indexLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.indexes, 'indexes.sql'), content);
}
// 07_foreign_keys (+ PKs + UNIQUEs)
step('07_foreign_keys...');
const constraintLines = fullSchema.match(/ALTER TABLE ONLY public\.\S+\s+ADD CONSTRAINT\s+[\s\S]*?;/gi) || [];
if (constraintLines.length > 0) {
const content = `-- Constraints (PK, FK, UNIQUE, CHECK)\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${constraintLines.length}\n\n` +
constraintLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.foreignKeys, 'constraints.sql'), content);
}
// 08_triggers
step('08_triggers...');
const triggerLines = fullSchema.match(/CREATE TRIGGER\s+[\s\S]*?;/gi) || [];
if (triggerLines.length > 0) {
const content = `-- Triggers\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${triggerLines.length}\n\n` +
triggerLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.triggers, 'triggers.sql'), content);
}
// 09_policies
step('09_policies...');
const policyEnables = fullSchema.match(/ALTER TABLE public\.\S+ ENABLE ROW LEVEL SECURITY;/gi) || [];
const policyCreates = fullSchema.match(/CREATE POLICY\s+[\s\S]*?;/gi) || [];
if (policyEnables.length > 0 || policyCreates.length > 0) {
const content = `-- RLS Policies\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Enable RLS: ${policyEnables.length} tabelas\n-- Policies: ${policyCreates.length}\n\n` +
'-- Enable RLS\n' + policyEnables.join('\n') + '\n\n' +
'-- Policies\n' + policyCreates.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.policies, 'policies.sql'), content);
}
// 10_grants
step('10_grants...');
const grantLines = fullSchema.match(/(?:GRANT|REVOKE)\s+[\s\S]*?;/gi) || [];
const publicGrants = grantLines.filter(g => /public\./i.test(g));
if (publicGrants.length > 0) {
const content = `-- Grants\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${publicGrants.length}\n\n` +
publicGrants.join('\n') + '\n';
fs.writeFileSync(path.join(dirs.grants, 'grants.sql'), content);
}
// Summary
log('');
ok('Schema exportado para schema/');
const summary = [
['00_full', '1 arquivo'],
['01_extensions', fs.readdirSync(dirs.extensions).length + ' arquivo(s)'],
['02_types', fs.readdirSync(dirs.types).length + ' arquivo(s)'],
['03_functions', fs.readdirSync(dirs.functions).length + ' arquivo(s), ' + funcBlocks.length + ' functions'],
['04_tables', fs.readdirSync(dirs.tables).length + ' arquivo(s), ' + tableBlocks.length + ' tabelas'],
['05_views', viewBlocks.length + ' views'],
['06_indexes', indexLines.length + ' indexes'],
['07_foreign_keys', constraintLines.length + ' constraints'],
['08_triggers', triggerLines.length + ' triggers'],
['09_policies', policyCreates.length + ' policies'],
['10_grants', publicGrants.length + ' grants'],
];
for (const [dir, desc] of summary) {
step(`${dir}: ${desc}`);
}
};
// ---- DASHBOARD ----
commands.dashboard = function (dateArg) {
title('Dashboard');
const scriptPath = path.join(ROOT, 'generate-dashboard.cjs');
if (!fs.existsSync(scriptPath)) {
err(`Script não encontrado: ${scriptPath}`);
process.exit(1);
}
const args = dateArg ? [scriptPath, dateArg] : [scriptPath];
const result = spawnSync('node', args, {
stdio: 'inherit',
cwd: ROOT,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
});
if (result.status !== 0) {
err('Falha ao gerar dashboard.');
process.exit(result.status || 1);
}
};
// ---- HELP ----
commands.help = function () {
log(`
${c.bold}AgenciaPsi — Database CLI${c.reset}
${c.cyan}Uso:${c.reset} node db.cjs <comando> [opções]
${c.cyan}Comandos:${c.reset}
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds + migrations)
Cria backup automático após concluir
${c.bold}backup${c.reset} Exporta banco para backups/YYYY-MM-DD/
Gera: schema.sql, data.sql, full_dump.sql,
${c.green}supabase_restore.sql${c.reset} (restauração completa)
${c.bold}restore [data]${c.reset} Restaura de um backup
Prioriza supabase_restore.sql se existir
Sem data = último backup disponível
Ex: node db.cjs restore 2026-04-17
${c.bold}migrate${c.reset} Aplica migrations pendentes (pasta migrations/)
Backup automático antes de aplicar
${c.bold}seed [grupo]${c.reset} Roda seeds (all, users, system, test_data)
Ex: node db.cjs seed system
${c.bold}status${c.reset} Mostra estado do banco, backups, migrations
${c.bold}diff${c.reset} Compara schema atual vs último backup
${c.bold}reset${c.reset} Reseta o banco e reinstala tudo do zero
${c.yellow}⚠ Cria backup antes de resetar${c.reset}
${c.bold}verify${c.reset} Verifica integridade dos dados essenciais
(tabelas + views definidas em db.config.json → verify)
${c.bold}schema-export${c.reset} Exporta schema separado em schema/
00_full, 01_extensions, 02_types, 03_functions,
04_tables (agrupado por domínio), 05_views,
06_indexes, 07_foreign_keys, 08_triggers,
09_policies, 10_grants
${c.bold}dashboard [data]${c.reset} Gera dashboard HTML interativo do banco
Tabelas por domínio + seção Infraestrutura + busca
Sem data = usa schema do backup mais recente
${c.bold}help${c.reset} Mostra esta ajuda
${c.cyan}Backup Supabase-compatible:${c.reset}
O comando ${c.bold}backup${c.reset} gera automaticamente o arquivo
${c.green}supabase_restore.sql${c.reset} que contém TUDO necessário
para restaurar o banco do zero:
• Schema public (tabelas, índices, triggers, functions)
• Dados auth.users + identities (autenticação)
• Todos os dados das tabelas public
• Metadados de storage buckets
Para restaurar manualmente:
${c.gray}# Opção 1: via CLI${c.reset}
node db.cjs restore
${c.gray}# Opção 2: direto no container${c.reset}
cat backups/2026-04-17/supabase_restore.sql | \\
docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}
${c.cyan}Exemplos:${c.reset}
${c.gray}# Primeira vez — instala tudo${c.reset}
node db.cjs setup
${c.gray}# Backup diário${c.reset}
node db.cjs backup
${c.gray}# Perdi o banco — restaurar${c.reset}
node db.cjs restore
${c.gray}# Nova migration${c.reset}
node db.cjs migrate
${c.gray}# Ver o que tem no banco${c.reset}
node db.cjs status
${c.gray}# Atualizar as pastas schema/*${c.reset}
node db.cjs schema-export
${c.gray}# Gerar dashboard HTML${c.reset}
node db.cjs dashboard
`);
};
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const [, , cmd, ...args] = process.argv;
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
commands.help();
process.exit(0);
}
if (!commands[cmd]) {
err(`Comando desconhecido: ${cmd}`);
log(` Use ${c.cyan}node db.cjs help${c.reset} para ver os comandos disponíveis.`);
process.exit(1);
}
try {
commands[cmd](...args);
} catch (e) {
err(`Erro: ${e.message}`);
if (process.env.DEBUG) console.error(e);
process.exit(1);
}