#!/usr/bin/env node // ============================================================================= // AgenciaPsi — Database CLI // ============================================================================= // Uso: node db.cjs [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 [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); }