@neetru/cli 2.26.6 → 2.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/dist/commands/db.d.ts +31 -0
- package/dist/commands/db.js +348 -2
- package/dist/commands/db.js.map +1 -1
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/db-pipeline/index.d.ts +3 -2
- package/dist/lib/db-pipeline/index.js +1 -1
- package/dist/lib/db-pipeline/index.js.map +1 -1
- package/dist/lib/db-pipeline/pipeline.d.ts +34 -1
- package/dist/lib/db-pipeline/pipeline.js +55 -0
- package/dist/lib/db-pipeline/pipeline.js.map +1 -1
- package/dist/lib/db-pipeline/types.d.ts +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
Convenção: [Keep a Changelog](https://keepachangelog.com/) + [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [2.27.1] — 2026-08-03 (diagnóstico acionável quando `db migrations confirm` falha)
|
|
6
|
+
|
|
7
|
+
> Correção do que o 2.27.0 assumiu errado: `exec-sql`/`db apply` SEMPRE conectam
|
|
8
|
+
> no Postgres como a role do app (via agente na VM, privilégio mínimo — nunca
|
|
9
|
+
> superuser), então NENHUM dos dois alcança um fix real de ownership drift —
|
|
10
|
+
> `ALTER TABLE ... OWNER TO` exige ser o dono atual ou superuser. Isso só foi
|
|
11
|
+
> descoberto ao vivo, em produção, com o pdv-agiliza: `exec-sql` classificou o
|
|
12
|
+
> `ALTER TABLE idempotency_keys OWNER TO app_ae263834e537;` como destrutiva
|
|
13
|
+
> corretamente, mas a migração falhou com `must be owner of table` mesmo após
|
|
14
|
+
> confirmar com MFA — 2 tentativas reais queimadas até entender a causa, porque
|
|
15
|
+
> `db migrations confirm` só reportava `status: failed`, sem mostrar o erro.
|
|
16
|
+
>
|
|
17
|
+
> O fix de ownership em si PRECISA de uma conexão superuser local na VM — fora
|
|
18
|
+
> do escopo deste CLI por design (ver runbook interno). O que este release
|
|
19
|
+
> resolve é a experiência de diagnóstico: `db migrations confirm` agora busca
|
|
20
|
+
> o erro real automaticamente e, para padrões conhecidos (ownership drift),
|
|
21
|
+
> mostra de cara que retry não resolve e qual o próximo passo.
|
|
22
|
+
|
|
23
|
+
### Corrigido
|
|
24
|
+
|
|
25
|
+
- `neetru db migrations confirm <id>`: quando o resultado é `status: 'failed'`, busca automaticamente o detalhe da migração (mesma chamada de `db migrations show`) e imprime o erro real — antes só aparecia "status: failed", exigindo um segundo comando pra descobrir o motivo.
|
|
26
|
+
- Diagnóstico acionável para o padrão `must be owner of` (ownership drift): explica que NÃO é falha transitória, que `db apply`/`exec-sql` nunca alcançam esse fix (conectam sempre como a role do app), e dá o comando exato (`gcloud compute ssh ... --tunnel-through-iap` + `sudo -u postgres psql`) pra resolver manualmente, uma vez.
|
|
27
|
+
|
|
28
|
+
### Verificação
|
|
29
|
+
|
|
30
|
+
- 1143 testes (suíte completa, +2 novos cobrindo o caminho `failed` com/sem diagnóstico conhecido), tsc limpo.
|
|
31
|
+
|
|
32
|
+
## [2.27.0] — 2026-07-28 (novo: `neetru db exec-sql` — SQL administrativo avulso pelo mesmo pipeline de segurança)
|
|
33
|
+
|
|
34
|
+
> Incidente pdv-agiliza: uma tabela (`idempotency_keys`) ficou com owner errado (`postgres` em vez de `app_ae263834e537`) por drift de uma recuperação de incidente fora do pipeline — bloqueando `ADD CONSTRAINT` (exige ser owner). O fix é 1 linha (`ALTER TABLE idempotency_keys OWNER TO app_ae263834e537;`), mas não existia um jeito de rodar SQL administrativo avulso contra produção: `neetru db apply` só aceita SQL gerado de `schema.ts` via drizzle-kit. Investigação confirmou que `POST /api/cli/v1/db/apply` já aceita `sql` livre no Core (audit+lock+classify+MFA-gate iguais a qualquer migração) — só faltava o CLI expor isso.
|
|
35
|
+
|
|
36
|
+
### Adicionado
|
|
37
|
+
|
|
38
|
+
- `neetru db exec-sql --sql "<SQL>"` (ou `--file <caminho.sql>`) — roda 1 instrução SQL administrativa pelo MESMO pipeline de `db apply` (rehearse → classify → push, mesmo gate de MFA se destrutivo). Não é um jeito de contornar o schema.ts declarativo pra mudanças de rotina — é uma válvula de escape auditada pra correções administrativas pontuais (ex.: drift de ownership).
|
|
39
|
+
- `--skip-rehearse` (mesmo contrato do `db apply`, aviso obrigatório) — necessário quando o SQL depende de estado que só existe em produção (ex.: uma role específica que não existe no Postgres efêmero em branco).
|
|
40
|
+
|
|
41
|
+
### Corrigido (achado em code review adversarial antes de publicar)
|
|
42
|
+
|
|
43
|
+
- A confirmação interativa rodava ANTES do pipeline — o operador confirmava "sim, rodar" sem saber se o SQL classificaria como aditivo ou destrutivo. Movida pra DEPOIS de rehearse+classify (via novo hook `confirmBeforePush`), mostrando a severidade real antes de perguntar.
|
|
44
|
+
|
|
45
|
+
### Verificação
|
|
46
|
+
|
|
47
|
+
- 1141 testes (suíte completa), tsc limpo.
|
|
48
|
+
- Codex review adversarial: sem bypass de MFA — `exec-sql` reusa o MESMO `@neetru/db-classifier` sem parser paralelo; achou a lacuna de ordem de confirmação (corrigida acima) e confirmou que o risco de `dbId`/diretório errado é herdado de `db apply`, não uma regressão nova.
|
|
49
|
+
|
|
5
50
|
## [2.26.6] — 2026-07-28 (fix crítico: DDL de `neetru db apply` não era idempotente — CREATE TYPE, CREATE TABLE, CREATE INDEX, ADD CONSTRAINT)
|
|
6
51
|
|
|
7
52
|
> Depois de 2.26.4/2.26.5 resolverem `--dialect` e o path Windows, o pdv-agiliza aplicou a migração e chegou até o Core — mas quebrou em `type "ft_entity_type" already exists`. Causa raiz: a premissa de que `drizzle-kit generate` embrulha `CREATE TYPE` num guard idempotente por padrão (escrita no fix da 2.26.3) estava ERRADA — confirmado ao vivo contra o schema real que `generate` emite DDL puro, sem guard nenhum, pra CREATE TYPE **nem** CREATE TABLE/INDEX/ADD CONSTRAINT. Qualquer produto com enum/FK no schema colide com "already exists" assim que o objeto existir no banco por qualquer via fora do pipeline (ex.: recuperação de incidente) — não é bug do schema do produto.
|
package/dist/commands/db.d.ts
CHANGED
|
@@ -52,6 +52,37 @@ export interface DbApplyOptions {
|
|
|
52
52
|
skipRehearse?: boolean;
|
|
53
53
|
}
|
|
54
54
|
export declare function runDbApply(opts?: DbApplyOptions): Promise<void>;
|
|
55
|
+
export interface DbExecSqlOptions {
|
|
56
|
+
dbName?: string;
|
|
57
|
+
env?: string;
|
|
58
|
+
/** SQL inline — mutuamente exclusivo com `filePath`. */
|
|
59
|
+
sql?: string;
|
|
60
|
+
/** Caminho de um arquivo .sql — mutuamente exclusivo com `sql`. */
|
|
61
|
+
filePath?: string;
|
|
62
|
+
dryRun?: boolean;
|
|
63
|
+
yes?: boolean;
|
|
64
|
+
json?: boolean;
|
|
65
|
+
/** Mesmo contrato de `DbApplyOptions.skipRehearse` — precisa ser logado. */
|
|
66
|
+
skipRehearse?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `neetru db exec-sql` — roda UMA instrução SQL administrativa avulsa contra
|
|
70
|
+
* o banco de um produto, pelo MESMO pipeline de segurança do `db apply`
|
|
71
|
+
* (rehearse → classify → push ao Core, com o MESMO gate de MFA se a
|
|
72
|
+
* classificação for destrutiva).
|
|
73
|
+
*
|
|
74
|
+
* bug_pdv_ownership_drift_2026-07-28: existe pra corrigir drift administrativo
|
|
75
|
+
* que não é parte do schema declarativo do produto (ex.: `ALTER TABLE x OWNER
|
|
76
|
+
* TO y` depois de uma tabela ter sido criada fora do pipeline numa recuperação
|
|
77
|
+
* de incidente) — sem isto, o único jeito de rodar SQL avulso contra produção
|
|
78
|
+
* seria chamar `/api/cli/v1/db/apply` manualmente por fora do CLI (o endpoint
|
|
79
|
+
* já aceita `sql` livre; isto só expõe essa capacidade já existente do Core).
|
|
80
|
+
*
|
|
81
|
+
* NÃO é um jeito de contornar o schema.ts declarativo pra mudanças de rotina
|
|
82
|
+
* — é uma válvula de escape auditada (mesmo lock/classify/MFA-gate do
|
|
83
|
+
* `db apply`) pra correções administrativas pontuais.
|
|
84
|
+
*/
|
|
85
|
+
export declare function runDbExecSql(opts?: DbExecSqlOptions): Promise<void>;
|
|
55
86
|
export interface DbMigrationsListOptions {
|
|
56
87
|
dbId?: string;
|
|
57
88
|
json?: boolean;
|
package/dist/commands/db.js
CHANGED
|
@@ -32,7 +32,7 @@ import { handleApiError } from '../lib/api-error.js';
|
|
|
32
32
|
import { renderTable, fmtTimestamp } from '../lib/render.js';
|
|
33
33
|
import { readDbJson, writeDbJson, normalizeEnv, } from '../lib/db-local/index.js';
|
|
34
34
|
import { safeExit } from '../utils/safe-exit.js';
|
|
35
|
-
import { runApplyPipeline, buildPipelineDeps, isPipelineError, } from '../lib/db-pipeline/index.js';
|
|
35
|
+
import { runApplyPipeline, runExecSqlPipeline, buildPipelineDeps, isPipelineError, } from '../lib/db-pipeline/index.js';
|
|
36
36
|
import { resolveStepUp } from '../lib/cli-write.js';
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
38
38
|
// Constantes e tipos locais
|
|
@@ -73,6 +73,31 @@ const ENGINE_LABELS = {
|
|
|
73
73
|
'vm-mysql-cluster': 'VM — MySQL (cluster HA)',
|
|
74
74
|
'nosql-vm': 'VM — MongoDB/NoSQL (self-hosted)',
|
|
75
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* bug_pdv_ownership_drift_2026-08-03: diagnóstico amigável pra erros
|
|
78
|
+
* conhecidos do Postgres/MySQL que o operador vai bater sem contexto — sem
|
|
79
|
+
* isso, `db migrations confirm` só mostrava "status: failed" e o operador
|
|
80
|
+
* tinha que rodar `db migrations show` à parte pra ver o erro cru do driver,
|
|
81
|
+
* e MESMO ASSIM não sabia o que fazer (queimou 2 tentativas de MFA achando
|
|
82
|
+
* que era falha transitória, quando era ownership drift — impossível de
|
|
83
|
+
* corrigir via `db apply`/`exec-sql`, que sempre conecta como a role app,
|
|
84
|
+
* nunca como superuser). Cada entrada aqui é (regex do erro cru → mensagem
|
|
85
|
+
* acionável). `null` de `diagnoseMigrationError` = sem padrão conhecido,
|
|
86
|
+
* mostra só o erro cru (comportamento anterior).
|
|
87
|
+
*/
|
|
88
|
+
function diagnoseMigrationError(rawError) {
|
|
89
|
+
if (/must be owner of/i.test(rawError)) {
|
|
90
|
+
return ('Isso é ownership drift, não falha transitória — NÃO adianta tentar de novo. ' +
|
|
91
|
+
'A tabela foi criada fora do fluxo normal (ex.: recuperação manual) e pertence a ' +
|
|
92
|
+
'outra role. `db apply`/`db exec-sql` SEMPRE conectam como a role do app (privilégio ' +
|
|
93
|
+
'mínimo, por design) — nenhum dos dois alcança um fix de ownership, mesmo com MFA. ' +
|
|
94
|
+
'Precisa de UMA conexão superuser local na VM, uma única vez:\n' +
|
|
95
|
+
' gcloud compute ssh <vm> --zone=<zona> --project=<projeto> --tunnel-through-iap\n' +
|
|
96
|
+
' sudo -u postgres psql -d <database> -c \'ALTER TABLE <tabela> OWNER TO <role_app>;\'\n' +
|
|
97
|
+
' Ache <vm>/<zona> com `neetru db status <dbId>` (serverId) + `neetru servers list`.');
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
76
101
|
// ---------------------------------------------------------------------------
|
|
77
102
|
// Helpers internos
|
|
78
103
|
// ---------------------------------------------------------------------------
|
|
@@ -926,6 +951,301 @@ export async function runDbApply(opts = {}) {
|
|
|
926
951
|
log.success(`Migração ${chalk.bold(result.migrationId)} enviada ao Core (${result.status}).`);
|
|
927
952
|
}
|
|
928
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* `neetru db exec-sql` — roda UMA instrução SQL administrativa avulsa contra
|
|
956
|
+
* o banco de um produto, pelo MESMO pipeline de segurança do `db apply`
|
|
957
|
+
* (rehearse → classify → push ao Core, com o MESMO gate de MFA se a
|
|
958
|
+
* classificação for destrutiva).
|
|
959
|
+
*
|
|
960
|
+
* bug_pdv_ownership_drift_2026-07-28: existe pra corrigir drift administrativo
|
|
961
|
+
* que não é parte do schema declarativo do produto (ex.: `ALTER TABLE x OWNER
|
|
962
|
+
* TO y` depois de uma tabela ter sido criada fora do pipeline numa recuperação
|
|
963
|
+
* de incidente) — sem isto, o único jeito de rodar SQL avulso contra produção
|
|
964
|
+
* seria chamar `/api/cli/v1/db/apply` manualmente por fora do CLI (o endpoint
|
|
965
|
+
* já aceita `sql` livre; isto só expõe essa capacidade já existente do Core).
|
|
966
|
+
*
|
|
967
|
+
* NÃO é um jeito de contornar o schema.ts declarativo pra mudanças de rotina
|
|
968
|
+
* — é uma válvula de escape auditada (mesmo lock/classify/MFA-gate do
|
|
969
|
+
* `db apply`) pra correções administrativas pontuais.
|
|
970
|
+
*/
|
|
971
|
+
export async function runDbExecSql(opts = {}) {
|
|
972
|
+
if (!opts.json) {
|
|
973
|
+
log.banner();
|
|
974
|
+
log.heading('neetru db exec-sql');
|
|
975
|
+
}
|
|
976
|
+
if (!opts.sql && !opts.filePath) {
|
|
977
|
+
const msg = 'Forneça --sql "<SQL>" ou --file <caminho.sql>.';
|
|
978
|
+
if (opts.json) {
|
|
979
|
+
jsonError(msg, 'sql_required');
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
log.error(msg);
|
|
983
|
+
}
|
|
984
|
+
safeExit(1);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (opts.sql && opts.filePath) {
|
|
988
|
+
const msg = '--sql e --file são mutuamente exclusivos. Use só um.';
|
|
989
|
+
if (opts.json) {
|
|
990
|
+
jsonError(msg, 'sql_and_file_conflict');
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
log.error(msg);
|
|
994
|
+
}
|
|
995
|
+
safeExit(1);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
let sql;
|
|
999
|
+
if (opts.filePath) {
|
|
1000
|
+
const sqlAbs = path.resolve(process.cwd(), opts.filePath);
|
|
1001
|
+
if (!fsSync.existsSync(sqlAbs)) {
|
|
1002
|
+
const msg = `Arquivo não encontrado: ${opts.filePath}`;
|
|
1003
|
+
if (opts.json) {
|
|
1004
|
+
jsonError(msg, 'file_not_found');
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
log.error(msg);
|
|
1008
|
+
}
|
|
1009
|
+
safeExit(1);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
sql = fsSync.readFileSync(sqlAbs, 'utf8');
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
sql = opts.sql;
|
|
1016
|
+
}
|
|
1017
|
+
if (sql.trim().length === 0) {
|
|
1018
|
+
const msg = 'SQL vazio — nada para executar.';
|
|
1019
|
+
if (opts.json) {
|
|
1020
|
+
jsonError(msg, 'sql_empty');
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
log.error(msg);
|
|
1024
|
+
}
|
|
1025
|
+
safeExit(1);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const cfg = await loadProductConfig();
|
|
1029
|
+
if (!cfg) {
|
|
1030
|
+
const msg = 'neetru.config.json não encontrado. Rode `neetru init` primeiro.';
|
|
1031
|
+
if (opts.json) {
|
|
1032
|
+
jsonError(msg, 'config_not_found');
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
log.error(msg);
|
|
1036
|
+
}
|
|
1037
|
+
safeExit(1);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const cwd = process.cwd();
|
|
1041
|
+
const dbJsonPath = path.resolve(cwd, DB_JSON_REL);
|
|
1042
|
+
const dbJson = readDbJson(dbJsonPath);
|
|
1043
|
+
if (dbJson.databases.length === 0) {
|
|
1044
|
+
const msg = 'Nenhum banco registrado em .neetru/db.json. Rode `neetru db init` primeiro.';
|
|
1045
|
+
if (opts.json) {
|
|
1046
|
+
jsonError(msg, 'no_databases');
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
log.error(msg);
|
|
1050
|
+
}
|
|
1051
|
+
safeExit(1);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
let dbEntry = dbJson.databases[0];
|
|
1055
|
+
if (opts.dbName) {
|
|
1056
|
+
const found = dbJson.databases.find((d) => d.name === opts.dbName);
|
|
1057
|
+
if (!found) {
|
|
1058
|
+
const msg = `Banco "${opts.dbName}" não encontrado em .neetru/db.json. Disponíveis: ${dbJson.databases.map((d) => d.name).join(', ')}`;
|
|
1059
|
+
if (opts.json) {
|
|
1060
|
+
jsonError(msg, 'db_not_found');
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
log.error(msg);
|
|
1064
|
+
}
|
|
1065
|
+
safeExit(1);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
dbEntry = found;
|
|
1069
|
+
}
|
|
1070
|
+
else if (dbJson.databases.length > 1) {
|
|
1071
|
+
const msg = 'Múltiplos bancos em .neetru/db.json. Use --db <nome>.';
|
|
1072
|
+
if (opts.json) {
|
|
1073
|
+
console.log(JSON.stringify({ ok: false, error: 'db_required', message: msg, available: dbJson.databases.map((d) => d.name) }));
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
log.error(msg);
|
|
1077
|
+
log.dim(` Bancos disponíveis: ${dbJson.databases.map((d) => d.name).join(', ')}`);
|
|
1078
|
+
}
|
|
1079
|
+
safeExit(1);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (NOSQL_ENGINES.includes(dbEntry.engine)) {
|
|
1083
|
+
const msg = `Banco "${dbEntry.name}" é engine NoSQL (${dbEntry.engine}) — exec-sql só vale pra engines SQL.`;
|
|
1084
|
+
if (opts.json) {
|
|
1085
|
+
jsonError(msg, 'engine_not_sql');
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
log.error(msg);
|
|
1089
|
+
}
|
|
1090
|
+
safeExit(1);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
let env;
|
|
1094
|
+
try {
|
|
1095
|
+
env = normalizeEnv(opts.env ?? 'dev-local');
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
const msg = `Ambiente inválido: "${opts.env}". Use dev-local, staging ou production.`;
|
|
1099
|
+
if (opts.json) {
|
|
1100
|
+
jsonError(msg, 'invalid_env');
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
log.error(msg);
|
|
1104
|
+
}
|
|
1105
|
+
safeExit(1);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const canonicalEnv = env;
|
|
1109
|
+
const dbId = dbEntry.ids[canonicalEnv];
|
|
1110
|
+
if (!dbId) {
|
|
1111
|
+
const msg = `Banco "${dbEntry.name}" não tem ID registrado para o ambiente "${env}". Execute \`neetru db init\` novamente ou verifique .neetru/db.json.`;
|
|
1112
|
+
if (opts.json) {
|
|
1113
|
+
jsonError(msg, 'missing_db_id');
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
log.error(msg);
|
|
1117
|
+
}
|
|
1118
|
+
safeExit(1);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const rehearseEngine = dbEntry.engine.includes('mysql') ? 'mysql' : 'postgres';
|
|
1122
|
+
if (!opts.json) {
|
|
1123
|
+
log.dim(` Banco: ${dbEntry.name} (${dbEntry.engine})`);
|
|
1124
|
+
log.dim(` Env: ${env}`);
|
|
1125
|
+
log.dim(` DB ID: ${dbId}`);
|
|
1126
|
+
log.dim('');
|
|
1127
|
+
log.dim(' SQL:');
|
|
1128
|
+
for (const line of sql.trim().split('\n'))
|
|
1129
|
+
log.dim(` ${line}`);
|
|
1130
|
+
log.dim('');
|
|
1131
|
+
if (opts.dryRun)
|
|
1132
|
+
log.warn(' Modo --dry-run: nenhuma alteração será enviada ao Core.');
|
|
1133
|
+
}
|
|
1134
|
+
// Mesmo contrato de aviso obrigatório que `db apply` (bug_pdv_rehearse_2026-07-27).
|
|
1135
|
+
if (opts.skipRehearse) {
|
|
1136
|
+
const msg = 'AVISO: --skip-rehearse ativo — o ensaio local (Postgres/MySQL efêmero) ' +
|
|
1137
|
+
'foi pulado. O SQL NÃO foi validado localmente; o Core/agente ainda vai ' +
|
|
1138
|
+
'rodá-lo contra a VM real.';
|
|
1139
|
+
if (opts.json) {
|
|
1140
|
+
process.stderr.write(JSON.stringify({ warning: msg }) + '\n');
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
log.warn(msg);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const phaseLabels = {
|
|
1147
|
+
rehearse: `Ensaiando em ${rehearseEngine === 'mysql' ? 'MySQL' : 'Postgres'} efêmero…`,
|
|
1148
|
+
classify: 'Classificando…',
|
|
1149
|
+
push: 'Enviando ao Core…',
|
|
1150
|
+
};
|
|
1151
|
+
let spinner = opts.json ? null : ora({ text: 'Iniciando…', color: 'blue' }).start();
|
|
1152
|
+
let result;
|
|
1153
|
+
try {
|
|
1154
|
+
const deps = buildPipelineDeps({
|
|
1155
|
+
onPhase: (phase) => {
|
|
1156
|
+
if (spinner)
|
|
1157
|
+
spinner.text = phaseLabels[phase] ?? phase;
|
|
1158
|
+
},
|
|
1159
|
+
...(opts.dryRun
|
|
1160
|
+
? {
|
|
1161
|
+
pushToCore: async () => {
|
|
1162
|
+
return { migrationId: '(dry-run)', status: 'dry_run' };
|
|
1163
|
+
},
|
|
1164
|
+
}
|
|
1165
|
+
: {}),
|
|
1166
|
+
}, rehearseEngine, !!opts.skipRehearse);
|
|
1167
|
+
// BUG FIX (Codex review adversarial, bug_pdv_ownership_drift_2026-07-28):
|
|
1168
|
+
// a confirmação rodava ANTES do pipeline — o operador confirmava "sim" às
|
|
1169
|
+
// cegas, sem saber se o SQL ia classificar como aditivo ou destrutivo.
|
|
1170
|
+
// Movido pra DEPOIS de rehearse+classify (via `confirmBeforePush`), pra
|
|
1171
|
+
// mostrar a severidade real antes de perguntar.
|
|
1172
|
+
const execDeps = {
|
|
1173
|
+
...deps,
|
|
1174
|
+
confirmBeforePush: opts.yes || opts.json
|
|
1175
|
+
? undefined
|
|
1176
|
+
: async ({ classification }) => {
|
|
1177
|
+
spinner?.stop();
|
|
1178
|
+
log.dim('');
|
|
1179
|
+
log.dim(` Severidade: ${severityColor(classification.overallSeverity)}`);
|
|
1180
|
+
const { confirmed } = await inquirer.prompt([
|
|
1181
|
+
{
|
|
1182
|
+
type: 'confirm',
|
|
1183
|
+
name: 'confirmed',
|
|
1184
|
+
message: `Executar este SQL (${classification.overallSeverity}) contra "${dbEntry.name}" (${env})?`,
|
|
1185
|
+
default: false,
|
|
1186
|
+
},
|
|
1187
|
+
]);
|
|
1188
|
+
if (confirmed)
|
|
1189
|
+
spinner = ora({ text: 'Enviando ao Core…', color: 'blue' }).start();
|
|
1190
|
+
return confirmed;
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
result = await runExecSqlPipeline({ sql, env: canonicalEnv, dbId }, execDeps);
|
|
1194
|
+
spinner?.stop();
|
|
1195
|
+
if (result.status === 'cancelled') {
|
|
1196
|
+
if (opts.json) {
|
|
1197
|
+
console.log(JSON.stringify({ ok: false, status: 'cancelled' }));
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
log.dim('Cancelado.');
|
|
1201
|
+
}
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
spinner?.fail('Pipeline falhou.');
|
|
1207
|
+
if (isPipelineError(err)) {
|
|
1208
|
+
const pe = err;
|
|
1209
|
+
if (opts.json) {
|
|
1210
|
+
jsonError(`Fase "${pe.phase}": ${pe.message}`, 'pipeline_failed');
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
log.error(`Fase "${pe.phase}": ${pe.message}`);
|
|
1214
|
+
}
|
|
1215
|
+
safeExit(5);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (opts.json) {
|
|
1219
|
+
jsonError(err.message ?? String(err), 'pipeline_failed');
|
|
1220
|
+
}
|
|
1221
|
+
else {
|
|
1222
|
+
log.error(err.message ?? String(err));
|
|
1223
|
+
}
|
|
1224
|
+
safeExit(5);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (opts.json) {
|
|
1228
|
+
console.log(JSON.stringify({
|
|
1229
|
+
ok: true,
|
|
1230
|
+
status: result.status,
|
|
1231
|
+
migrationId: result.migrationId ?? null,
|
|
1232
|
+
severity: result.severity ?? null,
|
|
1233
|
+
dryRun: !!opts.dryRun,
|
|
1234
|
+
}));
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
log.dim('');
|
|
1238
|
+
log.dim(` Severidade: ${severityColor(result.severity)}`);
|
|
1239
|
+
log.dim(` Migração ID: ${result.migrationId}`);
|
|
1240
|
+
log.dim(` Status: ${result.status}`);
|
|
1241
|
+
if (result.severity === 'destrutiva' && !opts.dryRun) {
|
|
1242
|
+
log.warn('Este SQL é destrutivo e requer confirmação antes de ser aplicado.');
|
|
1243
|
+
log.dim(` Execute: neetru db migrations confirm ${result.migrationId} --mfa <token>`);
|
|
1244
|
+
}
|
|
1245
|
+
else if (!opts.dryRun) {
|
|
1246
|
+
log.success(`SQL enviado ao Core como migração ${chalk.bold(result.migrationId)} (${result.status}).`);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
929
1249
|
export async function runDbMigrationsList(opts = {}) {
|
|
930
1250
|
log.banner();
|
|
931
1251
|
const qs = opts.dbId ? `?dbId=${encodeURIComponent(opts.dbId)}` : '';
|
|
@@ -984,8 +1304,34 @@ export async function runDbMigrationsConfirm(migrationId, opts) {
|
|
|
984
1304
|
spinner.fail('Falha ao confirmar migração.');
|
|
985
1305
|
handleApiError(err);
|
|
986
1306
|
}
|
|
1307
|
+
// bug_pdv_ownership_drift_2026-08-03: `resp` (confirm) não carrega o erro —
|
|
1308
|
+
// só {ok, migrationId, status}. Em `status: 'failed'`, busca o detalhe
|
|
1309
|
+
// (mesmo endpoint de `db migrations show`) pra mostrar o erro real e um
|
|
1310
|
+
// diagnóstico acionável AGORA, em vez do operador ter que rodar um segundo
|
|
1311
|
+
// comando e ainda ficar sem saber que retry não resolve (ownership drift
|
|
1312
|
+
// não é falha transitória — ver `diagnoseMigrationError`).
|
|
1313
|
+
let detail;
|
|
1314
|
+
if (resp.status === 'failed') {
|
|
1315
|
+
try {
|
|
1316
|
+
const detailResp = await apiRequest(`/api/cli/v1/db/migrations/${encodeURIComponent(resp.migrationId)}`);
|
|
1317
|
+
detail = detailResp.migration;
|
|
1318
|
+
}
|
|
1319
|
+
catch {
|
|
1320
|
+
// Best-effort — a confirmação em si já foi reportada acima.
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
987
1323
|
if (opts.json) {
|
|
988
|
-
console.log(JSON.stringify(resp, null, 2));
|
|
1324
|
+
console.log(JSON.stringify(detail ? { ...resp, error: detail.error, diagnosis: diagnoseMigrationError(detail.error ?? '') } : resp, null, 2));
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (detail?.error) {
|
|
1328
|
+
log.dim('');
|
|
1329
|
+
log.error(` Erro: ${detail.error}`);
|
|
1330
|
+
const diagnosis = diagnoseMigrationError(detail.error);
|
|
1331
|
+
if (diagnosis) {
|
|
1332
|
+
log.dim('');
|
|
1333
|
+
log.warn(diagnosis);
|
|
1334
|
+
}
|
|
989
1335
|
}
|
|
990
1336
|
}
|
|
991
1337
|
/**
|