7c20b518d4
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>
1065 lines
39 KiB
JavaScript
1065 lines
39 KiB
JavaScript
#!/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);
|
||
}
|