@lark-apaas/miaoda-cli 0.1.3-alpha.2a09432 → 0.1.3-alpha.3198e3b
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/dist/api/db/api.js +52 -16
- package/dist/api/db/client.js +97 -6
- package/dist/api/db/index.js +2 -1
- package/dist/cli/commands/db/index.js +9 -0
- package/dist/cli/commands/file/index.js +1 -1
- package/dist/cli/handlers/db/_destructive.js +67 -0
- package/dist/cli/handlers/db/_env.js +26 -0
- package/dist/cli/handlers/db/audit.js +94 -14
- package/dist/cli/handlers/db/changelog.js +33 -5
- package/dist/cli/handlers/db/migration.js +25 -26
- package/dist/cli/handlers/db/recovery.js +121 -65
- package/dist/cli/handlers/file/rm.js +8 -6
- package/dist/utils/poll.js +21 -13
- package/dist/utils/spinner.js +46 -0
- package/package.json +1 -1
package/dist/api/db/api.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.migrate = migrate;
|
|
|
13
13
|
exports.getMigrationStatus = getMigrationStatus;
|
|
14
14
|
exports.recover = recover;
|
|
15
15
|
exports.getRecoveryPreview = getRecoveryPreview;
|
|
16
|
+
exports.getRecoveryStatus = getRecoveryStatus;
|
|
16
17
|
exports.getDbQuota = getDbQuota;
|
|
17
18
|
const http_1 = require("../../utils/http");
|
|
18
19
|
const error_1 = require("../../utils/error");
|
|
@@ -47,8 +48,13 @@ async function mapDbHttpError(err, url, ctx, opts) {
|
|
|
47
48
|
(0, client_1.extractData)(body); // 业务 code 命中 → 抛 AppError;不命中走兜底
|
|
48
49
|
}
|
|
49
50
|
catch (appErr) {
|
|
50
|
-
if (appErr instanceof error_1.AppError
|
|
51
|
-
|
|
51
|
+
if (appErr instanceof error_1.AppError) {
|
|
52
|
+
// env 上下文存在时把"env not exist"系列错误统一重写成 UNKNOWN_ENV_VALUE
|
|
53
|
+
// —— 用户视角 vocab 错和 state 错都是「这个 env 不能用」,文案统一更好理解。
|
|
54
|
+
const decorated = (0, client_1.decorateEnvError)(appErr, opts?.env);
|
|
55
|
+
if (opts?.onErrorBody)
|
|
56
|
+
opts.onErrorBody(body, decorated);
|
|
57
|
+
throw decorated;
|
|
52
58
|
}
|
|
53
59
|
throw appErr;
|
|
54
60
|
}
|
|
@@ -103,6 +109,7 @@ async function execSql(opts) {
|
|
|
103
109
|
catch (err) {
|
|
104
110
|
(0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
105
111
|
await mapDbHttpError(err, url, 'Failed to execute SQL', {
|
|
112
|
+
env: opts.dbBranch,
|
|
106
113
|
onErrorBody: attachSqlPartialResults,
|
|
107
114
|
// SQL 路径单独的超时文案:PG 的 statement_timeout 会回滚整条事务,所以这里
|
|
108
115
|
// 明示「事务已回滚、没有改动落地」,并把 hint 引导到「简化 SQL / 加 LIMIT / 拆条」。
|
|
@@ -190,7 +197,7 @@ async function getSchema(opts) {
|
|
|
190
197
|
}
|
|
191
198
|
catch (err) {
|
|
192
199
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
193
|
-
await mapDbHttpError(err, url, 'Failed to get schema');
|
|
200
|
+
await mapDbHttpError(err, url, 'Failed to get schema', { env: opts.dbBranch });
|
|
194
201
|
throw err; // 不可达
|
|
195
202
|
}
|
|
196
203
|
const body = (await response.json());
|
|
@@ -217,7 +224,8 @@ async function importData(opts) {
|
|
|
217
224
|
records: opts.body.toString('utf8'),
|
|
218
225
|
};
|
|
219
226
|
if (opts.dbBranch !== undefined && opts.dbBranch !== '') {
|
|
220
|
-
|
|
227
|
+
// 兼容 `online` 别名 → 后端实际 dbBranch 名为 `main`
|
|
228
|
+
reqBody.dbBranch = opts.dbBranch === 'online' ? 'main' : opts.dbBranch;
|
|
221
229
|
}
|
|
222
230
|
const start = Date.now();
|
|
223
231
|
let response;
|
|
@@ -227,7 +235,7 @@ async function importData(opts) {
|
|
|
227
235
|
}
|
|
228
236
|
catch (err) {
|
|
229
237
|
(0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
230
|
-
await mapDbHttpError(err, url, 'Failed to import data');
|
|
238
|
+
await mapDbHttpError(err, url, 'Failed to import data', { env: opts.dbBranch });
|
|
231
239
|
throw err; // 不可达
|
|
232
240
|
}
|
|
233
241
|
// 后端 InnerAdminImportData 响应里 data 直接返 {tableName, recordCount, durationMs}
|
|
@@ -266,7 +274,7 @@ async function exportData(opts) {
|
|
|
266
274
|
}
|
|
267
275
|
catch (err) {
|
|
268
276
|
(0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
269
|
-
await mapDbHttpError(err, url, 'Failed to export data');
|
|
277
|
+
await mapDbHttpError(err, url, 'Failed to export data', { env: opts.dbBranch });
|
|
270
278
|
throw err; // 不可达
|
|
271
279
|
}
|
|
272
280
|
// 成功路径:响应 body 通常是原始 CSV/SQL/JSON 字节,但部分错误场景下网关会返
|
|
@@ -325,6 +333,7 @@ async function listDDLChangelog(opts) {
|
|
|
325
333
|
table: opts.table,
|
|
326
334
|
since: opts.since,
|
|
327
335
|
until: opts.until,
|
|
336
|
+
changeId: opts.changeId,
|
|
328
337
|
limit: opts.limit !== undefined ? String(opts.limit) : undefined,
|
|
329
338
|
cursor: opts.cursor,
|
|
330
339
|
dbBranch: opts.dbBranch,
|
|
@@ -337,7 +346,7 @@ async function listDDLChangelog(opts) {
|
|
|
337
346
|
}
|
|
338
347
|
catch (err) {
|
|
339
348
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
340
|
-
await mapDbHttpError(err, url, 'Failed to list DDL changelog');
|
|
349
|
+
await mapDbHttpError(err, url, 'Failed to list DDL changelog', { env: opts.dbBranch });
|
|
341
350
|
throw err; // 不可达
|
|
342
351
|
}
|
|
343
352
|
const body = (await response.json());
|
|
@@ -367,7 +376,7 @@ async function getAuditStatus(opts) {
|
|
|
367
376
|
}
|
|
368
377
|
catch (err) {
|
|
369
378
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
370
|
-
await mapDbHttpError(err, url, 'Failed to get audit status');
|
|
379
|
+
await mapDbHttpError(err, url, 'Failed to get audit status', { env: opts.dbBranch });
|
|
371
380
|
throw err; // 不可达
|
|
372
381
|
}
|
|
373
382
|
const respBody = (await response.json());
|
|
@@ -387,8 +396,10 @@ async function setAuditConfig(opts) {
|
|
|
387
396
|
};
|
|
388
397
|
if (opts.retention !== undefined && opts.retention !== '')
|
|
389
398
|
body.retention = opts.retention;
|
|
390
|
-
if (opts.dbBranch !== undefined && opts.dbBranch !== '')
|
|
391
|
-
|
|
399
|
+
if (opts.dbBranch !== undefined && opts.dbBranch !== '') {
|
|
400
|
+
// 兼容 `online` 别名 → 后端实际 dbBranch 名为 `main`
|
|
401
|
+
body.dbBranch = opts.dbBranch === 'online' ? 'main' : opts.dbBranch;
|
|
402
|
+
}
|
|
392
403
|
const start = Date.now();
|
|
393
404
|
let response;
|
|
394
405
|
try {
|
|
@@ -397,7 +408,7 @@ async function setAuditConfig(opts) {
|
|
|
397
408
|
}
|
|
398
409
|
catch (err) {
|
|
399
410
|
(0, client_1.traceHttp)('POST', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
400
|
-
await mapDbHttpError(err, url, 'Failed to set audit config');
|
|
411
|
+
await mapDbHttpError(err, url, 'Failed to set audit config', { env: opts.dbBranch });
|
|
401
412
|
throw err; // 不可达
|
|
402
413
|
}
|
|
403
414
|
const respBody = (await response.json());
|
|
@@ -425,7 +436,9 @@ async function listAuditLog(opts) {
|
|
|
425
436
|
}
|
|
426
437
|
const client = (0, http_1.getHttpClient)();
|
|
427
438
|
const url = (0, client_1.buildInnerUrl)(opts.appId, '/db/audit/log', {
|
|
428
|
-
tables
|
|
439
|
+
// IDL `Tables list<string>` 走重复 query key(?tables=A&tables=B)—— join(',')
|
|
440
|
+
// 会让 Hertz/Kitex 网关绑成 ["A,B"] 单元素切片,命中单表错误分支报错文案错乱。
|
|
441
|
+
tables: opts.tables,
|
|
429
442
|
since: opts.since,
|
|
430
443
|
until: opts.until,
|
|
431
444
|
limit: opts.limit !== undefined ? String(opts.limit) : undefined,
|
|
@@ -440,7 +453,7 @@ async function listAuditLog(opts) {
|
|
|
440
453
|
}
|
|
441
454
|
catch (err) {
|
|
442
455
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
443
|
-
await mapDbHttpError(err, url, 'Failed to list audit log');
|
|
456
|
+
await mapDbHttpError(err, url, 'Failed to list audit log', { env: opts.dbBranch });
|
|
444
457
|
throw err; // 不可达
|
|
445
458
|
}
|
|
446
459
|
const body = (await response.json());
|
|
@@ -517,7 +530,7 @@ async function getMigrationStatus(opts) {
|
|
|
517
530
|
}
|
|
518
531
|
catch (err) {
|
|
519
532
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
520
|
-
await mapDbHttpError(err, url, 'Failed to get migration status');
|
|
533
|
+
await mapDbHttpError(err, url, 'Failed to get migration status', { env: opts.dbBranch });
|
|
521
534
|
throw err; // 不可达
|
|
522
535
|
}
|
|
523
536
|
const body = (await response.json());
|
|
@@ -566,7 +579,30 @@ async function getRecoveryPreview(opts) {
|
|
|
566
579
|
}
|
|
567
580
|
catch (err) {
|
|
568
581
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
569
|
-
await mapDbHttpError(err, url, 'Failed to get recovery preview');
|
|
582
|
+
await mapDbHttpError(err, url, 'Failed to get recovery preview', { env: opts.dbBranch });
|
|
583
|
+
throw err; // 不可达
|
|
584
|
+
}
|
|
585
|
+
const body = (await response.json());
|
|
586
|
+
return (0, client_1.extractData)(body);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/recovery/status
|
|
590
|
+
* CLI apply 触发后定时调本接口直到 status=success/failed。dataloom 内部 Redis
|
|
591
|
+
* 维护 workspace 级 restore 状态,无需传 task id;workspace+dbBranch 维度同时
|
|
592
|
+
* 只允许一个 restore 进行中。
|
|
593
|
+
*/
|
|
594
|
+
async function getRecoveryStatus(opts) {
|
|
595
|
+
const client = (0, http_1.getHttpClient)();
|
|
596
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, '/db/recovery/status', { dbBranch: opts.dbBranch });
|
|
597
|
+
const start = Date.now();
|
|
598
|
+
let response;
|
|
599
|
+
try {
|
|
600
|
+
response = await client.get(url);
|
|
601
|
+
(0, client_1.traceHttp)('GET', url, start, response);
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
605
|
+
await mapDbHttpError(err, url, 'Failed to get recovery status', { env: opts.dbBranch });
|
|
570
606
|
throw err; // 不可达
|
|
571
607
|
}
|
|
572
608
|
const body = (await response.json());
|
|
@@ -587,7 +623,7 @@ async function getDbQuota(opts) {
|
|
|
587
623
|
}
|
|
588
624
|
catch (err) {
|
|
589
625
|
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
590
|
-
await mapDbHttpError(err, url, 'Failed to get db quota');
|
|
626
|
+
await mapDbHttpError(err, url, 'Failed to get db quota', { env: opts.dbBranch });
|
|
591
627
|
throw err; // 不可达
|
|
592
628
|
}
|
|
593
629
|
const respBody = (await response.json());
|
package/dist/api/db/client.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SQLSTATE_MAP = void 0;
|
|
3
|
+
exports.SQLSTATE_MAP = exports.ENV_CTX_TYPO = void 0;
|
|
4
4
|
exports.traceHttp = traceHttp;
|
|
5
5
|
exports.ensureInnerSuccess = ensureInnerSuccess;
|
|
6
6
|
exports.mapDataloomBizError = mapDataloomBizError;
|
|
7
|
+
exports.buildUnknownEnvError = buildUnknownEnvError;
|
|
8
|
+
exports.decorateEnvError = decorateEnvError;
|
|
7
9
|
exports.extractData = extractData;
|
|
8
10
|
exports.buildInnerUrl = buildInnerUrl;
|
|
9
11
|
const error_1 = require("../../utils/error");
|
|
@@ -69,7 +71,8 @@ function ensureInnerSuccess(body) {
|
|
|
69
71
|
*
|
|
70
72
|
* 优先级:
|
|
71
73
|
* 1. k_dl_1300002 + SQLSTATE 透传 → SQLSTATE_MAP
|
|
72
|
-
* 2. k_dl_1600000 + "Invalid DB Branch" →
|
|
74
|
+
* 2. k_dl_1600000 + "Invalid DB Branch" → UNKNOWN_ENV_VALUE 占位(mapDbHttpError
|
|
75
|
+
* 会用用户实际 --env 值通过 decorateEnvError 重写)
|
|
73
76
|
* 3. BIZ_ERR_MAP 命中 → 语义 code(可能带 hint)
|
|
74
77
|
* 4. 兜底 DB_API_<code>
|
|
75
78
|
*
|
|
@@ -84,7 +87,13 @@ function mapDataloomBizError(code, rawMessage) {
|
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
if (code === 'k_dl_1600000' && message.startsWith('Invalid DB Branch')) {
|
|
87
|
-
|
|
90
|
+
// 这里没有 user-facing --env 值(client 层不知道 CLI 入参),先用 server 侧返回的
|
|
91
|
+
// 分支名占位;mapDbHttpError 在拿到 env 上下文后会通过 decorateEnvError 重写成
|
|
92
|
+
// 用户传入的实际值。dataloom 错误文案历史上同时存在英文冒号和中文冒号「:」格式,
|
|
93
|
+
// 这里两种都接。
|
|
94
|
+
const m = /Invalid DB Branch[::\s]+([^\s]+)/i.exec(message);
|
|
95
|
+
const branch = m?.[1] ?? '<branch>';
|
|
96
|
+
return buildUnknownEnvError(branch);
|
|
88
97
|
}
|
|
89
98
|
const mapped = BIZ_ERR_MAP.get(code);
|
|
90
99
|
if (mapped) {
|
|
@@ -94,6 +103,71 @@ function mapDataloomBizError(code, rawMessage) {
|
|
|
94
103
|
}
|
|
95
104
|
return new error_1.AppError(`DB_API_${code}`, message);
|
|
96
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* typo / CLI vocab violation 路径用:state 未知,列双 env 让用户在 dev/online 之间选,
|
|
108
|
+
* 不引导 init(init 不会创建 typo 出来的分支)。
|
|
109
|
+
*/
|
|
110
|
+
exports.ENV_CTX_TYPO = {
|
|
111
|
+
available: ['dev', 'online'],
|
|
112
|
+
suggestInit: false,
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* 统一构造"--env 值不可用"错误。两类入口共用:
|
|
116
|
+
* - CLI vocab 校验(_env.ts validateEnv):用户传 dev/main/online 以外值
|
|
117
|
+
* - backend state 错(decorateEnvError):vocab 合法但 db 实际状态对不上
|
|
118
|
+
*
|
|
119
|
+
* `available` 跟 `suggestInit` 由调用方按错误码归类传入;user-facing 不展示 `main`
|
|
120
|
+
* 内部别名,统一用 `online`。
|
|
121
|
+
*/
|
|
122
|
+
function buildUnknownEnvError(env, ctx = exports.ENV_CTX_TYPO) {
|
|
123
|
+
const hints = [
|
|
124
|
+
`--env must match an existing database branch. Available: ${ctx.available.join(', ')}.`,
|
|
125
|
+
];
|
|
126
|
+
if (ctx.suggestInit) {
|
|
127
|
+
hints.push(`Run \`miaoda db migration init\` before using '${env}'.`);
|
|
128
|
+
}
|
|
129
|
+
return new error_1.AppError('UNKNOWN_ENV_VALUE', `Unknown --env value '${env}'`, {
|
|
130
|
+
next_actions: hints,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 把 backend env 相关错误统一重写成 UNKNOWN_ENV_VALUE 文案,按错误码做 state 推断:
|
|
135
|
+
*
|
|
136
|
+
* - DB_API_k_dl_1300033(非专家应用)→ 单 env (online),不建议 init
|
|
137
|
+
* - DB_API_k_dl_1300039(专家未 init)→ 单 env (online),建议 init
|
|
138
|
+
* - DB_API_k_dl_000007 + "db branch not found"(pg connection 兜底)→ 单 env,建议 init
|
|
139
|
+
* - UNKNOWN_ENV_VALUE(mapDataloomBizError 对 k_dl_1600000 的占位)→ typo path
|
|
140
|
+
*
|
|
141
|
+
* env 未传时不改写:避免把 backend 兜底文案(如 "Multi-env is not initialized")
|
|
142
|
+
* 改成 `Unknown --env value ''` 这种空字符串错误。
|
|
143
|
+
*/
|
|
144
|
+
function decorateEnvError(err, env) {
|
|
145
|
+
if (env === undefined || env === '')
|
|
146
|
+
return err;
|
|
147
|
+
const ctx = classifyEnvError(err);
|
|
148
|
+
if (!ctx)
|
|
149
|
+
return err;
|
|
150
|
+
return buildUnknownEnvError(env, ctx);
|
|
151
|
+
}
|
|
152
|
+
function classifyEnvError(err) {
|
|
153
|
+
if (err.code === 'DB_API_k_dl_1300033') {
|
|
154
|
+
// 非专家应用:init 也会被同一 guard 拒掉,建议 init 是误导
|
|
155
|
+
return { available: ['online'], suggestInit: false };
|
|
156
|
+
}
|
|
157
|
+
if (err.code === 'DB_API_k_dl_1300039') {
|
|
158
|
+
// 专家未 init:init 会创建 dev 分支
|
|
159
|
+
return { available: ['online'], suggestInit: true };
|
|
160
|
+
}
|
|
161
|
+
if (err.code === 'DB_API_k_dl_000007' && /db branch not found/i.test(err.message)) {
|
|
162
|
+
// pg connection 层 ErrParamsInvalid:大概率 pre-init;建议 init(非专家会被 init 路径拦)
|
|
163
|
+
return { available: ['online'], suggestInit: true };
|
|
164
|
+
}
|
|
165
|
+
if (err.code === 'UNKNOWN_ENV_VALUE') {
|
|
166
|
+
// mapDataloomBizError 对 k_dl_1600000 的占位:typo path,state 未知,列双 env
|
|
167
|
+
return exports.ENV_CTX_TYPO;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
97
171
|
/**
|
|
98
172
|
* 剥掉 PG 透传错误的 "ERROR:" 前缀:CLI 输出本身就会前缀 "Error:",
|
|
99
173
|
* 不去掉就会变成 `Error: ERROR: relation ...` 双重前缀,PRD 不要这种冗余。
|
|
@@ -201,9 +275,26 @@ function buildInnerUrl(appId, path, query) {
|
|
|
201
275
|
let url = `/v1/dataloom/app/${encodeURIComponent(appId)}${normalized}`;
|
|
202
276
|
if (query) {
|
|
203
277
|
const usp = new URLSearchParams();
|
|
204
|
-
for (const [k,
|
|
205
|
-
if (
|
|
206
|
-
|
|
278
|
+
for (const [k, rawV] of Object.entries(query)) {
|
|
279
|
+
if (rawV === undefined)
|
|
280
|
+
continue;
|
|
281
|
+
// 数组值 → 重复 key 编码(?k=v1&k=v2)。IDL 里 list<string> 类型经
|
|
282
|
+
// Hertz/Kitex HTTP→Thrift 网关绑定 slice 字段需要重复 key;如果改成
|
|
283
|
+
// join(',') 单串,后端会拿到 ["v1,v2"] 单元素切片而非 ["v1","v2"]。
|
|
284
|
+
if (typeof rawV !== 'string') {
|
|
285
|
+
for (const item of rawV) {
|
|
286
|
+
if (item === '')
|
|
287
|
+
continue;
|
|
288
|
+
usp.append(k, item);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (rawV === '')
|
|
293
|
+
continue;
|
|
294
|
+
// dbBranch 兼容用户视角的 `online` 别名 → 后端实际 dbBranch 名为 `main`,
|
|
295
|
+
// 这里集中归一,避免每个 API 函数各自处理。其他值(dev / 自定义分支)原样透传。
|
|
296
|
+
const norm = k === 'dbBranch' && rawV === 'online' ? 'main' : rawV;
|
|
297
|
+
usp.append(k, norm);
|
|
207
298
|
}
|
|
208
299
|
const qs = usp.toString();
|
|
209
300
|
if (qs)
|
package/dist/api/db/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pickTableDetail = exports.flattenSchemaList = exports.parseSqlResult = exports.SQLSTATE_MAP = exports.ensureInnerSuccess = exports.buildInnerUrl = exports.getDbQuota = exports.getRecoveryPreview = exports.recover = exports.getMigrationStatus = exports.migrate = exports.migrationInit = exports.listAuditLog = exports.setAuditConfig = exports.getAuditStatus = exports.listDDLChangelog = exports.exportData = exports.importData = exports.getSchema = exports.execSql = void 0;
|
|
3
|
+
exports.pickTableDetail = exports.flattenSchemaList = exports.parseSqlResult = exports.SQLSTATE_MAP = exports.ensureInnerSuccess = exports.buildInnerUrl = exports.getDbQuota = exports.getRecoveryStatus = exports.getRecoveryPreview = exports.recover = exports.getMigrationStatus = exports.migrate = exports.migrationInit = exports.listAuditLog = exports.setAuditConfig = exports.getAuditStatus = exports.listDDLChangelog = exports.exportData = exports.importData = exports.getSchema = exports.execSql = void 0;
|
|
4
4
|
var api_1 = require("./api");
|
|
5
5
|
Object.defineProperty(exports, "execSql", { enumerable: true, get: function () { return api_1.execSql; } });
|
|
6
6
|
Object.defineProperty(exports, "getSchema", { enumerable: true, get: function () { return api_1.getSchema; } });
|
|
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "migrate", { enumerable: true, get: function () {
|
|
|
15
15
|
Object.defineProperty(exports, "getMigrationStatus", { enumerable: true, get: function () { return api_1.getMigrationStatus; } });
|
|
16
16
|
Object.defineProperty(exports, "recover", { enumerable: true, get: function () { return api_1.recover; } });
|
|
17
17
|
Object.defineProperty(exports, "getRecoveryPreview", { enumerable: true, get: function () { return api_1.getRecoveryPreview; } });
|
|
18
|
+
Object.defineProperty(exports, "getRecoveryStatus", { enumerable: true, get: function () { return api_1.getRecoveryStatus; } });
|
|
18
19
|
Object.defineProperty(exports, "getDbQuota", { enumerable: true, get: function () { return api_1.getDbQuota; } });
|
|
19
20
|
var client_1 = require("./client");
|
|
20
21
|
Object.defineProperty(exports, "buildInnerUrl", { enumerable: true, get: function () { return client_1.buildInnerUrl; } });
|
|
@@ -4,6 +4,7 @@ exports.registerDbCommands = registerDbCommands;
|
|
|
4
4
|
const error_1 = require("../../../utils/error");
|
|
5
5
|
const index_1 = require("../../../cli/handlers/db/index");
|
|
6
6
|
const shared_1 = require("../../../cli/commands/shared");
|
|
7
|
+
const _env_1 = require("../../../cli/handlers/db/_env");
|
|
7
8
|
function parsePositiveInt(raw) {
|
|
8
9
|
const n = Number(raw);
|
|
9
10
|
if (!Number.isInteger(n) || n < 1) {
|
|
@@ -19,6 +20,13 @@ function registerDbCommands(program) {
|
|
|
19
20
|
// --env 注册在 db 父级,spec 把它列入 db --help 的 Global Flags;
|
|
20
21
|
// leaf 命令仍各自接收 --env 值(commander 解析时父级 option 自动适用于子命令)
|
|
21
22
|
.option('--env <name>', '指定目标环境(dev / online,仅专家模式应用支持)');
|
|
23
|
+
// 任意 leaf action 执行前先做 --env vocab 校验:用户拼错('onlin'/'prod' 等)立即报
|
|
24
|
+
// UNKNOWN_ENV_VALUE 避免后端无意义被打到。preAction hook 拿父级 dbCmd 的 --env 值
|
|
25
|
+
// —— commander 把父级 option 自动 propagate 给子命令的 optsWithGlobals。
|
|
26
|
+
dbCmd.hook('preAction', () => {
|
|
27
|
+
const env = dbCmd.opts().env;
|
|
28
|
+
(0, _env_1.validateEnv)(typeof env === 'string' ? env : undefined);
|
|
29
|
+
});
|
|
22
30
|
dbCmd.action(() => {
|
|
23
31
|
dbCmd.outputHelp();
|
|
24
32
|
});
|
|
@@ -224,6 +232,7 @@ Examples:
|
|
|
224
232
|
.option('--table <name>', '按表名过滤')
|
|
225
233
|
.option('--since <time>', '起始时间')
|
|
226
234
|
.option('--until <time>', '截止时间')
|
|
235
|
+
.option('--change-id <id>', '按 change_id 精确查询单条变更记录(指定后只返该 ID 一条,JSON 仍保持数组)')
|
|
227
236
|
.option('--limit <n>', '返回条数上限(默认 20)', parsePositiveInt, 20)
|
|
228
237
|
.option('--cursor <token>', '从上一页返回的游标位置继续获取')
|
|
229
238
|
.option('--all', '获取全部结果,自动翻页')
|
|
@@ -159,7 +159,7 @@ Notes:
|
|
|
159
159
|
Examples:
|
|
160
160
|
# 单文件删除(TTY 下需确认)
|
|
161
161
|
$ miaoda file rm /images/brand/1858537546760216.png
|
|
162
|
-
?
|
|
162
|
+
? Are you sure you want to permanently delete '/images/brand/1858537546760216.png'? (y/N) y
|
|
163
163
|
✓ Deleted /images/brand/1858537546760216.png
|
|
164
164
|
|
|
165
165
|
# 批量删除(混用 path 与 file_name)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// db 域内不可逆 / destructive 操作的统一确认门。
|
|
3
|
+
//
|
|
4
|
+
// 设计要点:
|
|
5
|
+
// 1. --yes:放行(CI / 脚本显式确认场景)
|
|
6
|
+
// 2. 非 TTY 且无 --yes:抛 DESTRUCTIVE_REQUIRES_CONFIRM,不要默认放行。
|
|
7
|
+
// 非 TTY 通常是 CI / agent / 管道场景,无人能交互回答 y/N,必须靠 --yes 显式确认。
|
|
8
|
+
// 3. TTY 且无 --yes:交互式 y/N(prompt 写 stderr,避免污染 --json 模式 stdout)
|
|
9
|
+
//
|
|
10
|
+
// 反例(已修复,见这次 commit):把 !isJsonMode() 拿来做确认门 —— --json 只改输出
|
|
11
|
+
// 格式,跟"用户已确认"不是同一语义。CI / agent 几乎总会带 --json 解析输出,等价于
|
|
12
|
+
// 把不可逆操作的确认变成了空门,跟 file rm 的模型也不一致。
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.confirmDestructive = confirmDestructive;
|
|
18
|
+
exports.assertDestructiveAllowedInTty = assertDestructiveAllowedInTty;
|
|
19
|
+
exports.askYesNo = askYesNo;
|
|
20
|
+
const node_readline_1 = __importDefault(require("node:readline"));
|
|
21
|
+
const render_1 = require("../../../utils/render");
|
|
22
|
+
const error_1 = require("../../../utils/error");
|
|
23
|
+
const DESTRUCTIVE_REQUIRES_CONFIRM_MSG = 'This operation is destructive. Rerun with --yes to confirm.';
|
|
24
|
+
/**
|
|
25
|
+
* 完整的"yes/tty/交互"决策门。直接用于无需 fetch 预览的简单确认场景(如 migration init)。
|
|
26
|
+
*
|
|
27
|
+
* 返回值:
|
|
28
|
+
* - true → 已确认,可执行
|
|
29
|
+
* - false → TTY 下用户输了 n,调用方应渲染 Aborted 并退出
|
|
30
|
+
*
|
|
31
|
+
* 异常:
|
|
32
|
+
* - DESTRUCTIVE_REQUIRES_CONFIRM:非 TTY 且无 --yes
|
|
33
|
+
*/
|
|
34
|
+
async function confirmDestructive(prompt, yes) {
|
|
35
|
+
if (yes === true)
|
|
36
|
+
return true;
|
|
37
|
+
if (!(0, render_1.isStdoutTty)()) {
|
|
38
|
+
throw new error_1.AppError('DESTRUCTIVE_REQUIRES_CONFIRM', DESTRUCTIVE_REQUIRES_CONFIRM_MSG);
|
|
39
|
+
}
|
|
40
|
+
return askYesNo(prompt);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 给"需要在 confirm 之前先 fetch 预览"的场景(migration apply / recovery apply 可选优化)用:
|
|
44
|
+
* 在非 TTY 无 --yes 时提前抛错,避免无意义触发后端 dry-run RPC。
|
|
45
|
+
*
|
|
46
|
+
* 调用后 TTY 路径仍需自行调 askYesNo / confirmDestructive 完成交互。
|
|
47
|
+
*/
|
|
48
|
+
function assertDestructiveAllowedInTty(yes) {
|
|
49
|
+
if (yes === true)
|
|
50
|
+
return;
|
|
51
|
+
if (!(0, render_1.isStdoutTty)()) {
|
|
52
|
+
throw new error_1.AppError('DESTRUCTIVE_REQUIRES_CONFIRM', DESTRUCTIVE_REQUIRES_CONFIRM_MSG);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 交互式 y/N。输入读 stdin,prompt 写 stderr —— stderr 避免污染 --json 模式 stdout 的 envelope。
|
|
57
|
+
* 仅 'y'(忽略大小写)算确认;其余一律视为否决。
|
|
58
|
+
*/
|
|
59
|
+
async function askYesNo(prompt) {
|
|
60
|
+
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
rl.question(prompt, (answer) => {
|
|
63
|
+
rl.close();
|
|
64
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// db 域内 --env 参数的 CLI vocab 校验。
|
|
3
|
+
//
|
|
4
|
+
// 用户视角的 vocab:
|
|
5
|
+
// - dev → 多环境沙箱分支
|
|
6
|
+
// - online → 生产分支(向后端发请求时映射成 main,client.ts/api.ts 已处理)
|
|
7
|
+
// - main → online 的别名,兼容历史脚本
|
|
8
|
+
//
|
|
9
|
+
// 配合 client.ts::buildUnknownEnvError,把 vocab 错(这里)跟 backend state 错
|
|
10
|
+
// (client.ts 处理)统一成同一类 UNKNOWN_ENV_VALUE,避免用户在两种失败之间困惑。
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.validateEnv = validateEnv;
|
|
13
|
+
const client_1 = require("../../../api/db/client");
|
|
14
|
+
const VOCAB = ['dev', 'main', 'online'];
|
|
15
|
+
const VOCAB_SET = new Set(VOCAB);
|
|
16
|
+
/**
|
|
17
|
+
* CLI vocab 校验。空值(未传 --env)放行,由后端按 workspace 默认 branch 处理。
|
|
18
|
+
* 不在 vocab 内 → 抛 UNKNOWN_ENV_VALUE,preAction hook 兜底,避免无意义触发 RPC。
|
|
19
|
+
*/
|
|
20
|
+
function validateEnv(env) {
|
|
21
|
+
if (env === undefined || env === '')
|
|
22
|
+
return;
|
|
23
|
+
if (VOCAB_SET.has(env))
|
|
24
|
+
return;
|
|
25
|
+
throw (0, client_1.buildUnknownEnvError)(env);
|
|
26
|
+
}
|
|
@@ -45,6 +45,7 @@ const output_1 = require("../../../utils/output");
|
|
|
45
45
|
const render_1 = require("../../../utils/render");
|
|
46
46
|
const time_1 = require("../../../utils/time");
|
|
47
47
|
const _operator_1 = require("../../../cli/handlers/db/_operator");
|
|
48
|
+
const index_1 = require("../../../api/db/index");
|
|
48
49
|
const VALID_RETENTION = new Set(['7d', '30d', '180d', '360d', 'forever']);
|
|
49
50
|
async function handleDbAuditStatus(table, opts) {
|
|
50
51
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
@@ -199,17 +200,48 @@ async function handleDbAuditList(tables, opts) {
|
|
|
199
200
|
// 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
|
|
200
201
|
const since = normalizeTime(opts.since, '--since');
|
|
201
202
|
const until = normalizeTime(opts.until, '--until');
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
203
|
+
// 多表场景:dataloom 对"任一表不存在"是 fail-fast(typo 应立即可见,符合 PRD 单表语义),
|
|
204
|
+
// 但 PRD 多表语义是"其他表继续返回,底部汇总跳过情况"。CLI 在这里前置校验:用 getSchema
|
|
205
|
+
// 的 tableNames 过滤模式(dataloom 端内存过滤,不存在的表静默忽略,不报错)拿到存在的
|
|
206
|
+
// 表集合,本地 diff 出缺失表,再用过滤后的表去查 listAuditLog。单表场景跳过预校验。
|
|
207
|
+
const isMulti = tables.length > 1;
|
|
208
|
+
let queryTables = tables;
|
|
209
|
+
const localMissing = [];
|
|
210
|
+
if (isMulti) {
|
|
211
|
+
const schemaResp = await api.db.getSchema({
|
|
212
|
+
appId,
|
|
213
|
+
tableNames: tables.join(','),
|
|
214
|
+
dbBranch: opts.env,
|
|
215
|
+
});
|
|
216
|
+
const existing = new Set((0, index_1.flattenSchemaList)(schemaResp.schema).map((t) => t.name));
|
|
217
|
+
for (const t of tables) {
|
|
218
|
+
if (!existing.has(t))
|
|
219
|
+
localMissing.push(t);
|
|
220
|
+
}
|
|
221
|
+
queryTables = tables.filter((t) => existing.has(t));
|
|
222
|
+
if (queryTables.length === 0) {
|
|
223
|
+
throw new error_1.AppError('TABLE_NOT_FOUND', `None of the requested tables exist: ${localMissing.join(', ')}`, { next_actions: ['Run `miaoda db schema list` to see all tables.'] });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let result;
|
|
227
|
+
try {
|
|
228
|
+
result = await api.db.listAuditLog({
|
|
229
|
+
appId,
|
|
230
|
+
tables: queryTables,
|
|
231
|
+
since,
|
|
232
|
+
until,
|
|
233
|
+
limit,
|
|
234
|
+
cursor: opts.cursor,
|
|
235
|
+
dbBranch: opts.env,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
throw decorateAuditListError(err, tables);
|
|
240
|
+
}
|
|
211
241
|
const visible = result.items;
|
|
212
|
-
|
|
242
|
+
// 服务端 skipped(audit 未启用,bare 名)+ CLI 本地探测 missing 合并;
|
|
243
|
+
// localMissing 自带 `(table not found)` 后缀,formatSkippedHint 透传渲染
|
|
244
|
+
const skipped = [...result.skipped, ...localMissing.map((t) => `${t} (table not found)`)];
|
|
213
245
|
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
214
246
|
// operator 后端用 JSON 字符串内嵌 {id, name},--json 输出还原成对象供下游消费
|
|
215
247
|
if ((0, output_1.isJsonMode)()) {
|
|
@@ -226,16 +258,16 @@ async function handleDbAuditList(tables, opts) {
|
|
|
226
258
|
}));
|
|
227
259
|
(0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
|
|
228
260
|
if (skipped.length > 0) {
|
|
229
|
-
process.stderr.write(
|
|
261
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
230
262
|
}
|
|
231
263
|
if (visible.length === 0)
|
|
232
264
|
process.exitCode = 1;
|
|
233
265
|
return;
|
|
234
266
|
}
|
|
235
267
|
if (visible.length === 0) {
|
|
236
|
-
(0, output_1.emit)('No audit
|
|
268
|
+
(0, output_1.emit)('No audit events found.');
|
|
237
269
|
if (skipped.length > 0) {
|
|
238
|
-
process.stderr.write(
|
|
270
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
239
271
|
}
|
|
240
272
|
process.exitCode = 1;
|
|
241
273
|
return;
|
|
@@ -261,7 +293,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
261
293
|
});
|
|
262
294
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
263
295
|
if (skipped.length > 0) {
|
|
264
|
-
process.stderr.write(
|
|
296
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
265
297
|
}
|
|
266
298
|
if (result.hasMore && result.nextCursor) {
|
|
267
299
|
process.stderr.write(`(more results; use --cursor '${result.nextCursor}')\n`);
|
|
@@ -287,6 +319,54 @@ function normalizeTime(input, flagName) {
|
|
|
287
319
|
throw err;
|
|
288
320
|
}
|
|
289
321
|
}
|
|
322
|
+
// PRD 41-48 行格式:`— Skipped 1 of 3 tables: orders (audit not enabled)`
|
|
323
|
+
// 服务端 skipped 数组是 audit 未启用的表(bare 名);上面 peel 循环拼出来的
|
|
324
|
+
// "<name> (table not found)" 已经自带 reason,t.includes('(') 走原样透传,bare 名
|
|
325
|
+
// 默认拼 "(audit not enabled)"。也兼容未来后端直接给带 reason 字符串的可能。
|
|
326
|
+
function formatSkippedHint(skipped, totalRequested) {
|
|
327
|
+
const items = skipped.map((t) => (t.includes('(') ? t : `${t} (audit not enabled)`)).join(', ');
|
|
328
|
+
return `— Skipped ${String(skipped.length)} of ${String(totalRequested)} tables: ${items}`;
|
|
329
|
+
}
|
|
330
|
+
// audit list 后端错误码加 hint:
|
|
331
|
+
// - k_dl_000005 表不存在 → 文案与 hint 对齐 `db schema get` 的统一格式
|
|
332
|
+
// - k_dl_1300040 单表 audit 未启用 → 指引 enable
|
|
333
|
+
// - k_dl_1300041 多表全部未启用 → 指引 status
|
|
334
|
+
function decorateAuditListError(err, tables) {
|
|
335
|
+
if (!(err instanceof error_1.AppError))
|
|
336
|
+
return err;
|
|
337
|
+
if (err.code === 'DB_API_k_dl_000005') {
|
|
338
|
+
// 不复用 dataloom 透传的 message(旧版/新版文案不一致:`table [x] not exist` /
|
|
339
|
+
// `table x not found`),统一改写成跟 schema get 一样的 `Table '<name>' does not exist`,
|
|
340
|
+
// 单表传 tables[0],多表场景兜底用解析到的 raw message 里第一个表名。
|
|
341
|
+
const t = tables.length > 0 ? tables[0] : (extractMissingTable(err.message) ?? '<table>');
|
|
342
|
+
return new error_1.AppError('TABLE_NOT_FOUND', `Table '${t}' does not exist`, {
|
|
343
|
+
next_actions: [`Did you mean another table? Run "miaoda db schema list" to see all tables.`],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (err.code === 'DB_API_k_dl_1300040') {
|
|
347
|
+
const t = tables[0] ?? '<table>';
|
|
348
|
+
return new error_1.AppError(err.code, err.message, {
|
|
349
|
+
next_actions: [`Enable with: miaoda db audit enable ${t}`],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (err.code === 'DB_API_k_dl_1300041') {
|
|
353
|
+
return new error_1.AppError(err.code, err.message, {
|
|
354
|
+
next_actions: ['Check audit status with: miaoda db audit status'],
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return err;
|
|
358
|
+
}
|
|
359
|
+
// extractMissingTable 从 dataloom 原始文案里抠表名兜底。dataloom 端有两种格式:
|
|
360
|
+
// - 旧版:`Table [foo] doesn't exist` / `table [foo] not exist`
|
|
361
|
+
// - 新版(本次 commits):`table foo not found`
|
|
362
|
+
// 都失配时返 null,调用方走 `<table>` 占位。
|
|
363
|
+
function extractMissingTable(msg) {
|
|
364
|
+
const bracket = /\[([^\]]+)\]/.exec(msg);
|
|
365
|
+
if (bracket)
|
|
366
|
+
return bracket[1];
|
|
367
|
+
const m = /table\s+([\w.]+)\s+not (?:exist|found)/i.exec(msg);
|
|
368
|
+
return m ? m[1] : null;
|
|
369
|
+
}
|
|
290
370
|
// 服务端 before/after 是 JSON 字符串透传,CLI JSON 输出再反序列化回对象,
|
|
291
371
|
// 给下游 jq 等工具消费。失败时透传原始字符串避免数据丢失。
|
|
292
372
|
function safeParseJson(s) {
|
|
@@ -75,10 +75,17 @@ async function handleDbChangelog(opts) {
|
|
|
75
75
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
76
76
|
const since = normalizeTime(opts.since, '--since');
|
|
77
77
|
const until = normalizeTime(opts.until, '--until');
|
|
78
|
+
// --change-id 是精确单条查询,CLI 透传给后端;后端若暂未支持过滤,CLI 端
|
|
79
|
+
// 兜底翻页找命中项,保证 PRD 「最多返回一条」的语义稳定。
|
|
80
|
+
const trimmed = opts.changeId?.trim();
|
|
81
|
+
const changeId = trimmed !== undefined && trimmed !== '' ? trimmed : undefined;
|
|
78
82
|
const allItems = [];
|
|
79
83
|
let cursor = opts.cursor;
|
|
80
84
|
let lastCursor = null;
|
|
81
85
|
let lastHasMore = false;
|
|
86
|
+
// 指定 --change-id 时强制开 --all,确保即使后端不识别 changeId 过滤参数,
|
|
87
|
+
// CLI 也能翻完所有页找到目标记录(changelog 默认按时间倒序,一般在前几页)。
|
|
88
|
+
const allMode = opts.all === true || changeId !== undefined;
|
|
82
89
|
// --all:循环到 hasMore=false;否则只拉一页
|
|
83
90
|
// 没传 --all 时翻页由调用方自己用 --cursor 串
|
|
84
91
|
for (;;) {
|
|
@@ -87,6 +94,7 @@ async function handleDbChangelog(opts) {
|
|
|
87
94
|
table: opts.table,
|
|
88
95
|
since,
|
|
89
96
|
until,
|
|
97
|
+
changeId,
|
|
90
98
|
limit: opts.limit,
|
|
91
99
|
cursor,
|
|
92
100
|
dbBranch: opts.env,
|
|
@@ -94,14 +102,28 @@ async function handleDbChangelog(opts) {
|
|
|
94
102
|
allItems.push(...page.items);
|
|
95
103
|
lastCursor = page.nextCursor;
|
|
96
104
|
lastHasMore = page.hasMore;
|
|
97
|
-
if (!
|
|
105
|
+
if (!allMode || !page.hasMore || !page.nextCursor)
|
|
98
106
|
break;
|
|
99
107
|
cursor = page.nextCursor;
|
|
108
|
+
// 后端命中过滤返单条时提前退出,免得继续无意义翻页
|
|
109
|
+
if (changeId !== undefined && allItems.some((it) => it.changeId === changeId))
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
// CLI 端兜底过滤:把 changeId 匹配的项筛出来。后端如果已支持,列表本身就只有
|
|
113
|
+
// 0 或 1 条,filter 是恒等运算;后端未支持时通过翻页 + 此过滤拿到精确单条。
|
|
114
|
+
if (changeId !== undefined) {
|
|
115
|
+
const matched = allItems.filter((it) => it.changeId === changeId);
|
|
116
|
+
allItems.length = 0;
|
|
117
|
+
allItems.push(...matched);
|
|
118
|
+
// 精确查询语义:永远当作"已取完",不再透出分页 cursor / has_more
|
|
119
|
+
lastCursor = null;
|
|
120
|
+
lastHasMore = false;
|
|
100
121
|
}
|
|
101
122
|
if ((0, output_1.isJsonMode)()) {
|
|
102
123
|
const rows = allItems.map(toRow);
|
|
103
|
-
// --all 时已经把所有页合一起返,has_more=false / next_cursor=null
|
|
104
|
-
|
|
124
|
+
// --all 或 --change-id 时已经把所有页合一起返,has_more=false / next_cursor=null。
|
|
125
|
+
// PRD: --change-id 即使没命中(rows 长度 0)也保持 data 为数组。
|
|
126
|
+
if (allMode) {
|
|
105
127
|
(0, output_1.emitPaged)((0, output_1.snakeCaseKeys)(rows), null, false);
|
|
106
128
|
}
|
|
107
129
|
else {
|
|
@@ -110,7 +132,13 @@ async function handleDbChangelog(opts) {
|
|
|
110
132
|
return;
|
|
111
133
|
}
|
|
112
134
|
if (allItems.length === 0) {
|
|
113
|
-
|
|
135
|
+
// --change-id 没命中时单独报错,便于 agent 区分"无变更"和"ID 不存在"
|
|
136
|
+
if (changeId !== undefined) {
|
|
137
|
+
(0, output_1.emit)(`No DDL change with id=${changeId} found.`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
(0, output_1.emit)('No DDL changes found.');
|
|
141
|
+
}
|
|
114
142
|
return;
|
|
115
143
|
}
|
|
116
144
|
const tty = (0, render_1.isStdoutTty)();
|
|
@@ -126,7 +154,7 @@ async function handleDbChangelog(opts) {
|
|
|
126
154
|
it.summary || '—',
|
|
127
155
|
]);
|
|
128
156
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
129
|
-
if (!
|
|
157
|
+
if (!allMode && lastHasMore && lastCursor) {
|
|
130
158
|
process.stderr.write(`(more results; use --cursor ${lastCursor} or --all)\n`);
|
|
131
159
|
}
|
|
132
160
|
}
|
|
@@ -32,14 +32,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
36
|
exports.handleDbMigrationInit = handleDbMigrationInit;
|
|
40
37
|
exports.handleDbMigrationDiff = handleDbMigrationDiff;
|
|
41
38
|
exports.handleDbMigrationApply = handleDbMigrationApply;
|
|
42
|
-
const node_readline_1 = __importDefault(require("node:readline"));
|
|
43
39
|
const api = __importStar(require("../../../api/index"));
|
|
44
40
|
const shared_1 = require("../../../cli/commands/shared");
|
|
45
41
|
const colors_1 = require("../../../utils/colors");
|
|
@@ -47,17 +43,17 @@ const error_1 = require("../../../utils/error");
|
|
|
47
43
|
const output_1 = require("../../../utils/output");
|
|
48
44
|
const poll_1 = require("../../../utils/poll");
|
|
49
45
|
const render_1 = require("../../../utils/render");
|
|
46
|
+
const spinner_1 = require("../../../utils/spinner");
|
|
47
|
+
const _destructive_1 = require("../../../cli/handlers/db/_destructive");
|
|
50
48
|
async function handleDbMigrationInit(opts) {
|
|
51
49
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
50
|
+
// 不可逆操作:--yes 直接放行;非 TTY 抛 DESTRUCTIVE_REQUIRES_CONFIRM;TTY 交互确认。
|
|
51
|
+
// 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
|
|
52
|
+
const suffix = opts.syncData ? ' (existing data will be copied to dev)' : '';
|
|
53
|
+
const ok = await (0, _destructive_1.confirmDestructive)(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `, opts.yes);
|
|
54
|
+
if (!ok) {
|
|
55
|
+
(0, output_1.emit)('Aborted.');
|
|
56
|
+
return;
|
|
61
57
|
}
|
|
62
58
|
let result;
|
|
63
59
|
try {
|
|
@@ -88,12 +84,16 @@ async function handleDbMigrationInit(opts) {
|
|
|
88
84
|
async function handleDbMigrationDiff(opts) {
|
|
89
85
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
90
86
|
let result;
|
|
87
|
+
const stopSpinner = (0, spinner_1.startSpinner)('Previewing migration diff (dev → online)');
|
|
91
88
|
try {
|
|
92
89
|
result = await api.db.migrate({ appId, dryRun: true });
|
|
93
90
|
}
|
|
94
91
|
catch (err) {
|
|
95
92
|
throw decorateMigrationError(err);
|
|
96
93
|
}
|
|
94
|
+
finally {
|
|
95
|
+
stopSpinner();
|
|
96
|
+
}
|
|
97
97
|
// PRD: diff 在无待发布变更时报错带 hint,而不是渲染空列表
|
|
98
98
|
if (result.changes.length === 0) {
|
|
99
99
|
throw new error_1.AppError('DB_API_k_dl_1300035', `No pending changes between ${result.from} and ${result.to}`, {
|
|
@@ -106,15 +106,20 @@ async function handleDbMigrationDiff(opts) {
|
|
|
106
106
|
}
|
|
107
107
|
async function handleDbMigrationApply(opts) {
|
|
108
108
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
109
|
-
// TTY
|
|
110
|
-
if (
|
|
109
|
+
// --yes 跳过预览 + 确认;否则 TTY 拉 diff 给用户审,非 TTY 直接拒(避免无意义 dry-run RPC)
|
|
110
|
+
if (opts.yes !== true) {
|
|
111
|
+
(0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
|
|
111
112
|
let preview;
|
|
113
|
+
const stopSpinner = (0, spinner_1.startSpinner)('Previewing migration diff (dev → online)');
|
|
112
114
|
try {
|
|
113
115
|
preview = await api.db.migrate({ appId, dryRun: true });
|
|
114
116
|
}
|
|
115
117
|
catch (err) {
|
|
116
118
|
throw decorateMigrationError(err);
|
|
117
119
|
}
|
|
120
|
+
finally {
|
|
121
|
+
stopSpinner();
|
|
122
|
+
}
|
|
118
123
|
if (preview.changes.length === 0) {
|
|
119
124
|
// PRD 文案 + hint
|
|
120
125
|
throw new error_1.AppError('DB_API_k_dl_1300035', `No pending changes between ${preview.from} and ${preview.to}`, {
|
|
@@ -123,8 +128,10 @@ async function handleDbMigrationApply(opts) {
|
|
|
123
128
|
],
|
|
124
129
|
});
|
|
125
130
|
}
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
// --json 模式跳过 pretty diff 渲染(污染 stdout envelope),但仍要求 confirm
|
|
132
|
+
if (!(0, output_1.isJsonMode)())
|
|
133
|
+
renderDiff(preview);
|
|
134
|
+
const ok = await (0, _destructive_1.askYesNo)(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
|
|
128
135
|
if (!ok) {
|
|
129
136
|
(0, output_1.emit)('Aborted.');
|
|
130
137
|
return;
|
|
@@ -145,6 +152,7 @@ async function handleDbMigrationApply(opts) {
|
|
|
145
152
|
const taskId = result.taskId;
|
|
146
153
|
const final = await (0, poll_1.pollUntilDone)({
|
|
147
154
|
label: 'migration apply',
|
|
155
|
+
spinnerLabel: 'Applying migration (dev → online)',
|
|
148
156
|
intervalMs: 1000,
|
|
149
157
|
fetch: () => api.db.getMigrationStatus({ appId, taskId }),
|
|
150
158
|
isDone: (cur) => {
|
|
@@ -224,12 +232,3 @@ function decorateMigrationError(err) {
|
|
|
224
232
|
return err;
|
|
225
233
|
}
|
|
226
234
|
}
|
|
227
|
-
async function confirm(prompt) {
|
|
228
|
-
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
229
|
-
return new Promise((resolve) => {
|
|
230
|
-
rl.question(prompt, (answer) => {
|
|
231
|
-
rl.close();
|
|
232
|
-
resolve(answer.trim().toLowerCase() === 'y');
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
}
|
|
@@ -32,13 +32,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
32
32
|
return result;
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
36
|
exports.handleDbRecoveryDiff = handleDbRecoveryDiff;
|
|
40
37
|
exports.handleDbRecoveryApply = handleDbRecoveryApply;
|
|
41
|
-
|
|
38
|
+
exports.formatRecoveryTargetForDisplay = formatRecoveryTargetForDisplay;
|
|
42
39
|
const api = __importStar(require("../../../api/index"));
|
|
43
40
|
const shared_1 = require("../../../cli/commands/shared");
|
|
44
41
|
const colors_1 = require("../../../utils/colors");
|
|
@@ -47,6 +44,7 @@ const output_1 = require("../../../utils/output");
|
|
|
47
44
|
const poll_1 = require("../../../utils/poll");
|
|
48
45
|
const render_1 = require("../../../utils/render");
|
|
49
46
|
const time_1 = require("../../../utils/time");
|
|
47
|
+
const _destructive_1 = require("../../../cli/handlers/db/_destructive");
|
|
50
48
|
// ── recovery diff ──
|
|
51
49
|
//
|
|
52
50
|
// PITR preview 是异步任务:dataloom 立即返 previewRequestId,CLI 自己 poll 直到 success/failed。
|
|
@@ -55,24 +53,42 @@ const time_1 = require("../../../utils/time");
|
|
|
55
53
|
async function handleDbRecoveryDiff(target, opts) {
|
|
56
54
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
57
55
|
const ts = normalizeTimestamp(target);
|
|
58
|
-
const preview = await runRecoveryPreview(appId, ts);
|
|
56
|
+
const preview = await runRecoveryPreview(appId, ts, target);
|
|
59
57
|
renderDiff(ts, preview);
|
|
60
58
|
}
|
|
61
59
|
async function handleDbRecoveryApply(target, opts) {
|
|
62
60
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
63
61
|
const ts = normalizeTimestamp(target);
|
|
64
|
-
// PRD 要求 apply 输出包含 N tables affected / Ms elapsed
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
const preview = await runRecoveryPreview(appId, ts);
|
|
62
|
+
// PRD 要求 apply 输出包含 N tables affected / Ms elapsed。
|
|
63
|
+
// tables_affected 从 preview 拿;elapsed 用 CLI 端墙钟(dataloom apply 是异步
|
|
64
|
+
// 触发,server-side elapsed=0;下面 poll 真终态后用本地计时器算 elapsed)。
|
|
65
|
+
const preview = await runRecoveryPreview(appId, ts, target);
|
|
68
66
|
const tablesAffected = preview.tablesAffected ?? preview.changes?.length ?? 0;
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
// 0 changes 短路:目标时间点与当前一致,apply 没意义。pretty 模式渲染 preview
|
|
68
|
+
// 的 "No changes — database is already at this state." 后直接退;--json 模式
|
|
69
|
+
// 返 status="no_changes" envelope 让下游识别。不进 confirm 也不下发 apply。
|
|
70
|
+
if ((preview.changes?.length ?? 0) === 0) {
|
|
71
|
+
if ((0, output_1.isJsonMode)()) {
|
|
72
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
73
|
+
status: 'no_changes',
|
|
74
|
+
target: ts,
|
|
75
|
+
tablesAffected: 0,
|
|
76
|
+
elapsedSeconds: 0,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
renderDiff(ts, preview);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// --yes 跳过;非 TTY 抛 DESTRUCTIVE_REQUIRES_CONFIRM;TTY 渲染 diff(pretty)+ 交互确认。
|
|
85
|
+
// 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
|
|
86
|
+
if (opts.yes !== true) {
|
|
87
|
+
(0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
|
|
88
|
+
// --json 模式跳过 pretty diff 渲染避免污染 stdout envelope;TTY pretty 模式渲染
|
|
89
|
+
if (!(0, output_1.isJsonMode)())
|
|
90
|
+
renderDiff(ts, preview);
|
|
91
|
+
const ok = await (0, _destructive_1.askYesNo)(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
|
|
76
92
|
if (!ok) {
|
|
77
93
|
(0, output_1.emit)('Aborted.');
|
|
78
94
|
return;
|
|
@@ -85,7 +101,12 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
85
101
|
catch (err) {
|
|
86
102
|
throw decorateRecoveryError(err);
|
|
87
103
|
}
|
|
88
|
-
|
|
104
|
+
// 关键:墙钟从 apply 触发瞬间开始,poll 真完成时停。dataloom restore 是异步触发,
|
|
105
|
+
// 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
|
|
106
|
+
// Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
|
|
107
|
+
const startedAt = Date.now();
|
|
108
|
+
await waitRecoveryDone(appId, ts, target);
|
|
109
|
+
const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
89
110
|
if ((0, output_1.isJsonMode)()) {
|
|
90
111
|
// PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
|
|
91
112
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -101,6 +122,38 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
101
122
|
`(${String(tablesAffected)} tables affected, ${String(elapsed)}s elapsed)`;
|
|
102
123
|
(0, output_1.emit)(tty ? colors_1.c.success(`✓ ${body}`) : `OK ${body}`);
|
|
103
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* 触发 BranchRestore 后 poll 直到任务终态。dataloom 端 Redis 缓存 workspace 级
|
|
127
|
+
* status,归一成 running / success / failed:
|
|
128
|
+
* - running → 继续 poll
|
|
129
|
+
* - success → 退出,CLI 渲染 ✓
|
|
130
|
+
* - failed → 抛 DB_API_k_dl_1300036 + errorMessage
|
|
131
|
+
*
|
|
132
|
+
* 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
|
|
133
|
+
* 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
|
|
134
|
+
*/
|
|
135
|
+
async function waitRecoveryDone(appId, target, rawTarget) {
|
|
136
|
+
try {
|
|
137
|
+
return await (0, poll_1.pollUntilDone)({
|
|
138
|
+
label: 'recovery apply',
|
|
139
|
+
spinnerLabel: `Restoring database (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
|
|
140
|
+
intervalMs: 2000,
|
|
141
|
+
fetch: () => api.db.getRecoveryStatus({ appId }),
|
|
142
|
+
isDone: (cur) => {
|
|
143
|
+
const status = (cur.status || '').toLowerCase();
|
|
144
|
+
if (status === 'success')
|
|
145
|
+
return { done: true, value: cur };
|
|
146
|
+
if (status === 'failed') {
|
|
147
|
+
throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? `recovery to ${target} failed`);
|
|
148
|
+
}
|
|
149
|
+
return { done: false };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
throw decorateRecoveryError(err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
104
157
|
/**
|
|
105
158
|
* 触发 PITR 预览任务并轮询到终态:
|
|
106
159
|
* 1. POST /db/recovery dryRun=true → previewRequestId
|
|
@@ -110,7 +163,7 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
110
163
|
* 补 hint。failed 状态下 dataloom 已把 errorMessage 翻好,CLI 包成 k_dl_1300036
|
|
111
164
|
* 让 decorateRecoveryError 命中窗口超限 hint。
|
|
112
165
|
*/
|
|
113
|
-
async function runRecoveryPreview(appId, ts) {
|
|
166
|
+
async function runRecoveryPreview(appId, ts, rawTarget) {
|
|
114
167
|
let triggered;
|
|
115
168
|
try {
|
|
116
169
|
triggered = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
@@ -125,6 +178,7 @@ async function runRecoveryPreview(appId, ts) {
|
|
|
125
178
|
try {
|
|
126
179
|
return await (0, poll_1.pollUntilDone)({
|
|
127
180
|
label: 'recovery preview',
|
|
181
|
+
spinnerLabel: `Previewing recovery impact (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
|
|
128
182
|
intervalMs: 1000,
|
|
129
183
|
fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
|
|
130
184
|
isDone: (cur) => {
|
|
@@ -148,6 +202,38 @@ async function runRecoveryPreview(appId, ts) {
|
|
|
148
202
|
}
|
|
149
203
|
}
|
|
150
204
|
// ── helpers ──
|
|
205
|
+
/**
|
|
206
|
+
* 把用户传入的 recovery target 解析成本地时区的 ISO 8601 字符串,
|
|
207
|
+
* 如 "2026-05-20T22:32:00+08:00"。无论用户传相对值(30m / 2h / 3d)还是绝对值,
|
|
208
|
+
* 都统一展示成"具体几点",让用户一眼看清楚"目标恢复点是我本地几点"——
|
|
209
|
+
* "30m ago"这种相对描述虽然原样直观,但跟 backend dataloom 收到的实际时间点没有
|
|
210
|
+
* 显式对照,遇到长 poll 时间过去后会让人怀疑"算的到底是哪个点"。
|
|
211
|
+
*
|
|
212
|
+
* 不用 UTC 'Z' 后缀:用户读时间不直观;带 +/-HH:MM offset 一眼即懂时差。
|
|
213
|
+
* 解析失败 → 原样返回兜底,避免 spinner 渲染崩。
|
|
214
|
+
*/
|
|
215
|
+
function formatRecoveryTargetForDisplay(rawInput) {
|
|
216
|
+
const trimmed = rawInput.trim();
|
|
217
|
+
let ms;
|
|
218
|
+
try {
|
|
219
|
+
ms = (0, time_1.parseTimeToMs)(trimmed);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return trimmed;
|
|
223
|
+
}
|
|
224
|
+
return formatIsoWithLocalOffset(ms);
|
|
225
|
+
}
|
|
226
|
+
function formatIsoWithLocalOffset(ms) {
|
|
227
|
+
const d = new Date(ms);
|
|
228
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
229
|
+
// getTimezoneOffset 返回本地相对 UTC 的差值(分钟)— UTC 在东八区是 -480
|
|
230
|
+
const offsetMinutes = -d.getTimezoneOffset();
|
|
231
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
232
|
+
const absOffset = Math.abs(offsetMinutes);
|
|
233
|
+
const tz = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
|
234
|
+
return (`${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
235
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${tz}`);
|
|
236
|
+
}
|
|
151
237
|
/**
|
|
152
238
|
* 把用户传入的时间统一成 ISO 8601 UTC 字符串发给 dataloom。
|
|
153
239
|
*
|
|
@@ -171,20 +257,20 @@ function normalizeTimestamp(input) {
|
|
|
171
257
|
}
|
|
172
258
|
// PRD diff 输出,三套:
|
|
173
259
|
//
|
|
174
|
-
// TTY pretty(缩进 prose、带 Unicode
|
|
260
|
+
// TTY pretty(缩进 prose、带 Unicode 箭头、表名按列对齐):
|
|
175
261
|
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
176
262
|
//
|
|
177
263
|
// tables affected: 2
|
|
178
|
-
// users: +3 rows, -1 row
|
|
179
|
-
// orders: table will be restored
|
|
264
|
+
// users: +3 rows, -1 row
|
|
265
|
+
// orders: table will be restored
|
|
180
266
|
//
|
|
181
267
|
// estimated time: ~30s
|
|
182
268
|
//
|
|
183
269
|
// non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
|
|
184
270
|
// Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
185
271
|
// tables_affected\t2
|
|
186
|
-
// users\t+3 rows, -1 row
|
|
187
|
-
// orders\ttable will be restored
|
|
272
|
+
// users\t+3 rows, -1 row
|
|
273
|
+
// orders\ttable will be restored
|
|
188
274
|
// estimated_time\t30
|
|
189
275
|
//
|
|
190
276
|
// --json:标准 envelope,字段名固定 snake_case。
|
|
@@ -200,7 +286,6 @@ function renderDiff(target, preview) {
|
|
|
200
286
|
table: c.table,
|
|
201
287
|
inserted: c.inserted,
|
|
202
288
|
deleted: c.deleted,
|
|
203
|
-
modified: c.modified,
|
|
204
289
|
action: c.action,
|
|
205
290
|
droppedAt: c.droppedAt,
|
|
206
291
|
})),
|
|
@@ -250,51 +335,31 @@ function renderDiffPipe(target, changes, tablesAffected, estimated) {
|
|
|
250
335
|
return lines.join('\n');
|
|
251
336
|
}
|
|
252
337
|
function describeChange(c) {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
338
|
+
// dataloom 端 action 是 PRD 三态 + unavailable 边界:
|
|
339
|
+
// restore_table — schema diff 显示该表在目标时间点存在但当前没有
|
|
340
|
+
// drop_table — 该表当前有但目标时间点没有
|
|
341
|
+
// alter_table — 两侧都在但结构有差异(列 / 索引 / 关系等)
|
|
342
|
+
// unavailable — PITR diff 算不出来,droppedAt 字段复用透传 message
|
|
343
|
+
// 没 action 时是数据行数变化,走下面的 +N / -N 渲染。
|
|
255
344
|
if (c.action === 'restore_table') {
|
|
256
|
-
|
|
257
|
-
return `table will be restored${ts}`;
|
|
345
|
+
return 'table will be restored';
|
|
258
346
|
}
|
|
259
|
-
if (c.action === '
|
|
260
|
-
|
|
261
|
-
return `table will be dropped${ts}`;
|
|
347
|
+
if (c.action === 'drop_table') {
|
|
348
|
+
return 'table will be dropped';
|
|
262
349
|
}
|
|
263
|
-
if (c.action === '
|
|
264
|
-
|
|
265
|
-
return `table will be created${ts}`;
|
|
266
|
-
}
|
|
267
|
-
if (c.action?.startsWith('schema_') === true) {
|
|
268
|
-
// dataloom 透出的 schema diffType 来自 schema_handler/common/constants:
|
|
269
|
-
// - create: 当前没这表 / 目标时间点有 → 恢复后表会被建出来(PRD 用 restored 表达)
|
|
270
|
-
// - drop: 当前有 / 目标时间点没 → 恢复后会被删掉
|
|
271
|
-
// - alter: 两侧都在但结构有差异 → 列 / 索引 / 关系等会被改回
|
|
272
|
-
const diffType = c.action.slice('schema_'.length);
|
|
273
|
-
const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : '';
|
|
274
|
-
switch (diffType) {
|
|
275
|
-
case 'create':
|
|
276
|
-
return `table will be restored${ts}`;
|
|
277
|
-
case 'drop':
|
|
278
|
-
return `table will be dropped${ts}`;
|
|
279
|
-
case 'alter':
|
|
280
|
-
return `schema will be altered${ts}`;
|
|
281
|
-
default:
|
|
282
|
-
return `schema changed${diffType !== '' ? ` (${diffType})` : ''}${ts}`;
|
|
283
|
-
}
|
|
350
|
+
if (c.action === 'alter_table') {
|
|
351
|
+
return 'table will be altered';
|
|
284
352
|
}
|
|
285
353
|
if (c.action === 'unavailable') {
|
|
286
|
-
// dataloom 端 count 失败的表,复用 droppedAt 透传 message
|
|
287
354
|
return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== '' ? `: ${c.droppedAt}` : ''}`;
|
|
288
355
|
}
|
|
289
|
-
// 数据变更:+N rows / -N rows
|
|
356
|
+
// 数据变更:+N rows / -N rows
|
|
290
357
|
const parts = [];
|
|
291
358
|
if (c.inserted !== undefined && c.inserted !== 0)
|
|
292
359
|
parts.push(`+${String(c.inserted)} rows`);
|
|
293
360
|
if (c.deleted !== undefined && c.deleted !== 0) {
|
|
294
361
|
parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? 'row' : 'rows'}`);
|
|
295
362
|
}
|
|
296
|
-
if (c.modified !== undefined && c.modified !== 0)
|
|
297
|
-
parts.push(`~${String(c.modified)} rows modified`);
|
|
298
363
|
return parts.length === 0 ? 'no changes' : parts.join(', ');
|
|
299
364
|
}
|
|
300
365
|
/**
|
|
@@ -346,12 +411,3 @@ function decorateRecoveryError(err) {
|
|
|
346
411
|
return err;
|
|
347
412
|
}
|
|
348
413
|
}
|
|
349
|
-
async function confirm(prompt) {
|
|
350
|
-
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
351
|
-
return new Promise((resolve) => {
|
|
352
|
-
rl.question(prompt, (answer) => {
|
|
353
|
-
rl.close();
|
|
354
|
-
resolve(answer.trim().toLowerCase() === 'y');
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
}
|
|
@@ -104,11 +104,11 @@ async function resolveDeleteInputs(appId, paths, names) {
|
|
|
104
104
|
return { resolved, errors };
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
* 删除前 TTY
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* `firstInput`
|
|
111
|
-
*
|
|
107
|
+
* 删除前 TTY 二次确认。文案强调"permanently"(删除不可撤销,无回收站):
|
|
108
|
+
* - 单文件:`? Are you sure you want to permanently delete '<input>'? (y/N)`
|
|
109
|
+
* - 多文件:`? Are you sure you want to permanently delete N files? (y/N)`
|
|
110
|
+
* `firstInput` 是用户传入的第一个值(path 或 file_name),单文件时直接展示给
|
|
111
|
+
* 用户核对目标。
|
|
112
112
|
*/
|
|
113
113
|
async function confirm(count, firstInput) {
|
|
114
114
|
const rl = node_readline_1.default.createInterface({
|
|
@@ -116,7 +116,9 @@ async function confirm(count, firstInput) {
|
|
|
116
116
|
output: process.stderr,
|
|
117
117
|
});
|
|
118
118
|
return new Promise((resolve) => {
|
|
119
|
-
const prompt = count === 1
|
|
119
|
+
const prompt = count === 1
|
|
120
|
+
? `? Are you sure you want to permanently delete '${firstInput}'? (y/N) `
|
|
121
|
+
: `? Are you sure you want to permanently delete ${String(count)} files? (y/N) `;
|
|
120
122
|
rl.question(prompt, (answer) => {
|
|
121
123
|
rl.close();
|
|
122
124
|
resolve(answer.trim().toLowerCase() === 'y');
|
package/dist/utils/poll.js
CHANGED
|
@@ -2,24 +2,32 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.pollUntilDone = pollUntilDone;
|
|
4
4
|
const error_1 = require("../utils/error");
|
|
5
|
+
const spinner_1 = require("../utils/spinner");
|
|
5
6
|
async function pollUntilDone(opts) {
|
|
6
7
|
const interval = opts.intervalMs ?? 1000;
|
|
7
8
|
const timeout = opts.timeoutMs ?? 300_000;
|
|
8
9
|
const deadline = Date.now() + timeout;
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
// 仅当传 spinnerLabel 时启动 spinner;非 TTY / JSON 模式内部自动 noop
|
|
11
|
+
const stopSpinner = opts.spinnerLabel ? (0, spinner_1.startSpinner)(opts.spinnerLabel) : () => undefined;
|
|
12
|
+
try {
|
|
13
|
+
// 立即拉一次(绝大多数轻量任务在 dataloom 端已是同步语义,第一次 fetch 就能拿到 success)
|
|
14
|
+
for (;;) {
|
|
15
|
+
const cur = await opts.fetch();
|
|
16
|
+
const verdict = opts.isDone(cur);
|
|
17
|
+
if (verdict.done)
|
|
18
|
+
return verdict.value;
|
|
19
|
+
if (Date.now() + interval > deadline) {
|
|
20
|
+
throw new error_1.AppError('TASK_TIMEOUT', `${opts.label} did not complete within ${String(Math.round(timeout / 1000))}s`, {
|
|
21
|
+
next_actions: [
|
|
22
|
+
'The task may still be running server-side. Retry the command, or check `miaoda db migration diff` to verify final state.',
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
await sleep(interval);
|
|
21
27
|
}
|
|
22
|
-
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
stopSpinner();
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
function sleep(ms) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startSpinner = startSpinner;
|
|
4
|
+
const config_1 = require("./config");
|
|
5
|
+
/**
|
|
6
|
+
* TTY 友好的 loading 指示器,写到 stderr 上避免污染 stdout(保证 --json 输出干净)。
|
|
7
|
+
*
|
|
8
|
+
* 设计:
|
|
9
|
+
* - 仅 stderr.isTTY 且非 --json 模式启用;其他情况静默(CI / 管道 / JSON 输出场景)
|
|
10
|
+
* - 80ms 一帧 braille 旋转字符 + 累计 elapsed 秒数
|
|
11
|
+
* - 返回 stop() 函数;调用方 finally 里调用,无论成功失败都清掉 spinner 行
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* const stop = startSpinner('Applying migration to online');
|
|
15
|
+
* try { await someLongTask(); } finally { stop(); }
|
|
16
|
+
*/
|
|
17
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
18
|
+
const HIDE_CURSOR = '[?25l';
|
|
19
|
+
const SHOW_CURSOR = '[?25h';
|
|
20
|
+
const CLEAR_LINE = '\r[K';
|
|
21
|
+
function startSpinner(label) {
|
|
22
|
+
// 非 TTY / JSON 模式:静默 noop,避免污染管道与结构化输出
|
|
23
|
+
if (!process.stderr.isTTY || (0, config_1.getConfig)().json) {
|
|
24
|
+
return () => {
|
|
25
|
+
/* noop */
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let frame = 0;
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
process.stderr.write(HIDE_CURSOR);
|
|
31
|
+
const render = () => {
|
|
32
|
+
const elapsedSec = Math.round((Date.now() - start) / 1000);
|
|
33
|
+
process.stderr.write(`${CLEAR_LINE}${FRAMES[frame]} ${label}... ${String(elapsedSec)}s`);
|
|
34
|
+
frame = (frame + 1) % FRAMES.length;
|
|
35
|
+
};
|
|
36
|
+
render();
|
|
37
|
+
const timer = setInterval(render, 80);
|
|
38
|
+
let stopped = false;
|
|
39
|
+
return () => {
|
|
40
|
+
if (stopped)
|
|
41
|
+
return;
|
|
42
|
+
stopped = true;
|
|
43
|
+
clearInterval(timer);
|
|
44
|
+
process.stderr.write(`${CLEAR_LINE}${SHOW_CURSOR}`);
|
|
45
|
+
};
|
|
46
|
+
}
|