@lark-apaas/miaoda-cli 0.1.3-alpha.fbac0be → 0.1.3-alpha.fc1e1a9
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 +24 -0
- package/dist/api/db/index.js +2 -1
- package/dist/api/file/client.js +40 -0
- package/dist/cli/commands/db/index.js +1 -1
- package/dist/cli/commands/file/index.js +22 -2
- package/dist/cli/handlers/db/_operator.js +35 -0
- package/dist/cli/handlers/db/audit.js +76 -14
- package/dist/cli/handlers/db/changelog.js +4 -2
- package/dist/cli/handlers/db/recovery.js +57 -46
- 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");
|
|
@@ -572,6 +573,29 @@ async function getRecoveryPreview(opts) {
|
|
|
572
573
|
const body = (await response.json());
|
|
573
574
|
return (0, client_1.extractData)(body);
|
|
574
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/recovery/status
|
|
578
|
+
* CLI apply 触发后定时调本接口直到 status=success/failed。dataloom 内部 Redis
|
|
579
|
+
* 维护 workspace 级 restore 状态,无需传 task id;workspace+dbBranch 维度同时
|
|
580
|
+
* 只允许一个 restore 进行中。
|
|
581
|
+
*/
|
|
582
|
+
async function getRecoveryStatus(opts) {
|
|
583
|
+
const client = (0, http_1.getHttpClient)();
|
|
584
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, '/db/recovery/status', { dbBranch: opts.dbBranch });
|
|
585
|
+
const start = Date.now();
|
|
586
|
+
let response;
|
|
587
|
+
try {
|
|
588
|
+
response = await client.get(url);
|
|
589
|
+
(0, client_1.traceHttp)('GET', url, start, response);
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
593
|
+
await mapDbHttpError(err, url, 'Failed to get recovery status');
|
|
594
|
+
throw err; // 不可达
|
|
595
|
+
}
|
|
596
|
+
const body = (await response.json());
|
|
597
|
+
return (0, client_1.extractData)(body);
|
|
598
|
+
}
|
|
575
599
|
// ── db quota → InnerAdminGetDbQuota ──
|
|
576
600
|
/**
|
|
577
601
|
* 后端:GET /v1/dataloom/app/{appId}/db/quota?dbBranch=
|
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; } });
|
package/dist/api/file/client.js
CHANGED
|
@@ -90,7 +90,20 @@ const BIZ_ERR_MAP = new Map(Object.entries({
|
|
|
90
90
|
message: 'A file at this path already exists',
|
|
91
91
|
hint: 'Rename the target or delete the existing file first (`miaoda file rm`).',
|
|
92
92
|
},
|
|
93
|
+
// k_ec_000040:file-storage 上游引擎层连不上下游服务(依赖抖动 / 远端 RPC 失败)。
|
|
94
|
+
// 文案不向用户暴露内部 code,统一成 INTERNAL_API_ERROR + 重试引导。
|
|
95
|
+
k_ec_000040: {
|
|
96
|
+
code: 'INTERNAL_API_ERROR',
|
|
97
|
+
message: 'Service temporarily unavailable',
|
|
98
|
+
hint: 'Please retry the command. If it keeps failing, share the x-tt-logid (via --verbose) with the on-call team.',
|
|
99
|
+
},
|
|
93
100
|
}));
|
|
101
|
+
// k_ec_* 命名空间是 file-storage 引擎层通用错误(基础设施 / 上游 RPC),不是
|
|
102
|
+
// 用户输入问题。无显式条目时统一兜底成 INTERNAL_API_ERROR + 重试 hint,避免
|
|
103
|
+
// 暴露内部 code 给最终用户。
|
|
104
|
+
function isEngineCommonError(code) {
|
|
105
|
+
return /^k_ec_\d+$/.test(code);
|
|
106
|
+
}
|
|
94
107
|
function ensureSuccess(body) {
|
|
95
108
|
// 后端 envelope 字段历史遗留多种命名:
|
|
96
109
|
// - ErrorCode / error_code: 部分接口
|
|
@@ -106,6 +119,15 @@ function ensureSuccess(body) {
|
|
|
106
119
|
next_actions: mapped.hint ? [mapped.hint] : undefined,
|
|
107
120
|
});
|
|
108
121
|
}
|
|
122
|
+
// k_ec_* 引擎层错误统一兜底:用户视角是"服务暂时不可用",重试即可,
|
|
123
|
+
// 不要把 `File API error [k_ec_000xxx]: <内部翻译>` 这种半成品文案抛给用户。
|
|
124
|
+
if (isEngineCommonError(code)) {
|
|
125
|
+
throw new error_1.AppError('INTERNAL_API_ERROR', 'Service temporarily unavailable', {
|
|
126
|
+
next_actions: [
|
|
127
|
+
`Please retry the command. If it keeps failing, share the x-tt-logid (via --verbose) with the on-call team. (upstream code: ${code})`,
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
109
131
|
throw new error_1.AppError(`FILE_API_${code}`, `File API error [${code}]: ${backendMsg}`);
|
|
110
132
|
}
|
|
111
133
|
/** 从 HttpError 的 response 里尝试读 body,用于拿后端返的业务 ErrorCode。 */
|
|
@@ -130,6 +152,24 @@ async function mapHttpError(err, opts) {
|
|
|
130
152
|
if (body) {
|
|
131
153
|
const code = body.ErrorCode ?? body.error_code ?? '';
|
|
132
154
|
if (code && code !== '0') {
|
|
155
|
+
const mapped = BIZ_ERR_MAP.get(code);
|
|
156
|
+
if (mapped) {
|
|
157
|
+
const msg = body.ErrorMessage ??
|
|
158
|
+
body.error_message ??
|
|
159
|
+
body.Message ??
|
|
160
|
+
mapped.message ??
|
|
161
|
+
err.message;
|
|
162
|
+
throw new error_1.AppError(mapped.code, mapped.message ?? msg, {
|
|
163
|
+
next_actions: mapped.hint ? [mapped.hint] : undefined,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (isEngineCommonError(code)) {
|
|
167
|
+
throw new error_1.AppError('INTERNAL_API_ERROR', 'Service temporarily unavailable', {
|
|
168
|
+
next_actions: [
|
|
169
|
+
`Please retry the command. If it keeps failing, share the x-tt-logid (via --verbose) with the on-call team. (upstream code: ${code})`,
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
133
173
|
const msg = body.ErrorMessage ?? body.error_message ?? body.Message ?? err.message;
|
|
134
174
|
throw new error_1.AppError(`FILE_API_${code}`, `File API error [${code}]: ${msg}`);
|
|
135
175
|
}
|
|
@@ -79,7 +79,7 @@ Examples:
|
|
|
79
79
|
.command('list')
|
|
80
80
|
.summary('列出所有表的概览(表名、行数、大小等)')
|
|
81
81
|
.description('列出当前应用所有表的概览信息:表名、描述、行数、大小、列数、最近更新时间。\n' +
|
|
82
|
-
'查看单表完整结构请用 `schema get`;查看 DDL 变更历史请用 `changelog
|
|
82
|
+
'查看单表完整结构请用 `schema get`;查看 DDL 变更历史请用 `changelog`。')
|
|
83
83
|
.usage('[flags]')
|
|
84
84
|
.action(async function () {
|
|
85
85
|
await (0, index_1.handleDbSchemaList)({ ...this.optsWithGlobals(), appId: (0, shared_1.resolveAppId)({}) });
|
|
@@ -212,9 +212,29 @@ Examples:
|
|
|
212
212
|
`);
|
|
213
213
|
fileCmd
|
|
214
214
|
.command('quota')
|
|
215
|
-
.summary('
|
|
215
|
+
.summary('查看文件存储的用量与配额')
|
|
216
|
+
.description('查看文件存储的用量与配额。')
|
|
216
217
|
.usage('[flags]')
|
|
217
218
|
.action(async function () {
|
|
218
219
|
await (0, index_1.handleFileQuota)(this.optsWithGlobals());
|
|
219
|
-
})
|
|
220
|
+
})
|
|
221
|
+
.addHelpText('after', `
|
|
222
|
+
Examples:
|
|
223
|
+
$ miaoda file quota
|
|
224
|
+
Storage: 150 MB / 1 GB (15%)
|
|
225
|
+
Files: 42
|
|
226
|
+
|
|
227
|
+
# --json
|
|
228
|
+
$ miaoda file quota --json
|
|
229
|
+
{
|
|
230
|
+
"data": {
|
|
231
|
+
"storage_used_bytes": 157286400,
|
|
232
|
+
"storage_quota_bytes": 1073741824,
|
|
233
|
+
"usage_percent": 15,
|
|
234
|
+
"files": 42
|
|
235
|
+
},
|
|
236
|
+
"next_cursor": null,
|
|
237
|
+
"has_more": false
|
|
238
|
+
}
|
|
239
|
+
`);
|
|
220
240
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// changelog / audit 共用:把后端透传的 operator 字符串还原成 {id, name}。
|
|
3
|
+
//
|
|
4
|
+
// 后端约定:Operator 字段值是 `{"id":"<user_id>","name":"<resolved_name>"}` JSON
|
|
5
|
+
// 字符串(dataloom inner_service.encodeOperator 生成),用同一字段同时承载机器可
|
|
6
|
+
// 识别的 user_id 与人类可读 name。
|
|
7
|
+
//
|
|
8
|
+
// 选择"字符串内嵌 JSON"而不是 IDL struct,是为了避免 dataloom_inner.thrift +
|
|
9
|
+
// kitex_gen 跨仓库改造;CLI 端 JSON.parse 拆开即可分场景渲染:
|
|
10
|
+
// - --json 模式:返 {id, name} 对象(agent / 下游能区分同名用户)
|
|
11
|
+
// - pretty 模式:只取 name(兼容 PRD 原始 string 形态)
|
|
12
|
+
//
|
|
13
|
+
// 历史数据 / 旧版后端没接这层时,operator 仍是纯字符串。该函数兼容:
|
|
14
|
+
// - 解析失败 → 当作 {id: raw, name: raw}
|
|
15
|
+
// - 解析成功但缺字段 → 用现有字段兜底另一个
|
|
16
|
+
// 解析失败默认不报错,保留 raw 作为兜底显示文本。
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.parseOperator = parseOperator;
|
|
19
|
+
function parseOperator(raw) {
|
|
20
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
21
|
+
return { id: '', name: '' };
|
|
22
|
+
}
|
|
23
|
+
if (!raw.startsWith('{')) {
|
|
24
|
+
return { id: raw, name: raw };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const obj = JSON.parse(raw);
|
|
28
|
+
const id = typeof obj.id === 'string' ? obj.id : '';
|
|
29
|
+
const name = typeof obj.name === 'string' && obj.name !== '' ? obj.name : id;
|
|
30
|
+
return { id, name };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { id: raw, name: raw };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -44,6 +44,7 @@ const error_1 = require("../../../utils/error");
|
|
|
44
44
|
const output_1 = require("../../../utils/output");
|
|
45
45
|
const render_1 = require("../../../utils/render");
|
|
46
46
|
const time_1 = require("../../../utils/time");
|
|
47
|
+
const _operator_1 = require("../../../cli/handlers/db/_operator");
|
|
47
48
|
const VALID_RETENTION = new Set(['7d', '30d', '180d', '360d', 'forever']);
|
|
48
49
|
async function handleDbAuditStatus(table, opts) {
|
|
49
50
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
@@ -198,25 +199,32 @@ async function handleDbAuditList(tables, opts) {
|
|
|
198
199
|
// 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
|
|
199
200
|
const since = normalizeTime(opts.since, '--since');
|
|
200
201
|
const until = normalizeTime(opts.until, '--until');
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
202
|
+
let result;
|
|
203
|
+
try {
|
|
204
|
+
result = await api.db.listAuditLog({
|
|
205
|
+
appId,
|
|
206
|
+
tables,
|
|
207
|
+
since,
|
|
208
|
+
until,
|
|
209
|
+
limit,
|
|
210
|
+
cursor: opts.cursor,
|
|
211
|
+
dbBranch: opts.env,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw decorateAuditListError(err, tables);
|
|
216
|
+
}
|
|
210
217
|
const visible = result.items;
|
|
211
218
|
const skipped = result.skipped;
|
|
212
219
|
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
220
|
+
// operator 后端用 JSON 字符串内嵌 {id, name},--json 输出还原成对象供下游消费
|
|
213
221
|
if ((0, output_1.isJsonMode)()) {
|
|
214
222
|
const items = visible.map((it) => ({
|
|
215
223
|
event_id: it.eventId,
|
|
216
224
|
event_time: it.eventTime,
|
|
217
225
|
target_table: it.targetTable,
|
|
218
226
|
type: it.type,
|
|
219
|
-
operator: it.operator,
|
|
227
|
+
operator: (0, _operator_1.parseOperator)(it.operator),
|
|
220
228
|
summary: it.summary,
|
|
221
229
|
// before/after 服务端是 JSON 字符串,反序列化回结构化对象供下游消费
|
|
222
230
|
...(it.before !== undefined ? { before: safeParseJson(it.before) } : {}),
|
|
@@ -224,7 +232,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
224
232
|
}));
|
|
225
233
|
(0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
|
|
226
234
|
if (skipped.length > 0) {
|
|
227
|
-
process.stderr.write(
|
|
235
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
228
236
|
}
|
|
229
237
|
if (visible.length === 0)
|
|
230
238
|
process.exitCode = 1;
|
|
@@ -233,7 +241,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
233
241
|
if (visible.length === 0) {
|
|
234
242
|
(0, output_1.emit)('No audit log entries found.');
|
|
235
243
|
if (skipped.length > 0) {
|
|
236
|
-
process.stderr.write(
|
|
244
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
237
245
|
}
|
|
238
246
|
process.exitCode = 1;
|
|
239
247
|
return;
|
|
@@ -247,12 +255,19 @@ async function handleDbAuditList(tables, opts) {
|
|
|
247
255
|
const rows = visible.map((it) => {
|
|
248
256
|
const eventTime = (0, render_1.formatTime)(it.eventTime, tty);
|
|
249
257
|
// event_id 完整透传——PRD 截图里的 "..." 只是文档省略写法,不是 CLI 行为
|
|
250
|
-
|
|
258
|
+
// operator pretty 只展示 name;--json 上面已经走 parseOperator 输出 {id, name}
|
|
259
|
+
const cells = [
|
|
260
|
+
eventTime,
|
|
261
|
+
it.type,
|
|
262
|
+
it.eventId,
|
|
263
|
+
(0, _operator_1.parseOperator)(it.operator).name || '—',
|
|
264
|
+
it.summary,
|
|
265
|
+
];
|
|
251
266
|
return isMultiTable ? [it.targetTable, ...cells] : cells;
|
|
252
267
|
});
|
|
253
268
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
254
269
|
if (skipped.length > 0) {
|
|
255
|
-
process.stderr.write(
|
|
270
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
256
271
|
}
|
|
257
272
|
if (result.hasMore && result.nextCursor) {
|
|
258
273
|
process.stderr.write(`(more results; use --cursor '${result.nextCursor}')\n`);
|
|
@@ -278,6 +293,53 @@ function normalizeTime(input, flagName) {
|
|
|
278
293
|
throw err;
|
|
279
294
|
}
|
|
280
295
|
}
|
|
296
|
+
// PRD 41-48 行格式:`— Skipped 1 of 3 tables: orders (audit not enabled)`
|
|
297
|
+
// 服务端约定 skipped 列表 = audit 未启用的表(表不存在直接 raise k_dl_000005),
|
|
298
|
+
// 所以原因固定为 "audit not enabled",无需带每项 reason。
|
|
299
|
+
function formatSkippedHint(skipped, totalRequested) {
|
|
300
|
+
const items = skipped.map((t) => `${t} (audit not enabled)`).join(', ');
|
|
301
|
+
return `— Skipped ${String(skipped.length)} of ${String(totalRequested)} tables: ${items}`;
|
|
302
|
+
}
|
|
303
|
+
// audit list 后端错误码加 hint:
|
|
304
|
+
// - k_dl_000005 表不存在 → 文案与 hint 对齐 `db schema get` 的统一格式
|
|
305
|
+
// - k_dl_1300040 单表 audit 未启用 → 指引 enable
|
|
306
|
+
// - k_dl_1300041 多表全部未启用 → 指引 status
|
|
307
|
+
function decorateAuditListError(err, tables) {
|
|
308
|
+
if (!(err instanceof error_1.AppError))
|
|
309
|
+
return err;
|
|
310
|
+
if (err.code === 'DB_API_k_dl_000005') {
|
|
311
|
+
// 不复用 dataloom 透传的 message(旧版/新版文案不一致:`table [x] not exist` /
|
|
312
|
+
// `table x not found`),统一改写成跟 schema get 一样的 `Table '<name>' does not exist`,
|
|
313
|
+
// 单表传 tables[0],多表场景兜底用解析到的 raw message 里第一个表名。
|
|
314
|
+
const t = tables.length > 0 ? tables[0] : (extractMissingTable(err.message) ?? '<table>');
|
|
315
|
+
return new error_1.AppError('TABLE_NOT_FOUND', `Table '${t}' does not exist`, {
|
|
316
|
+
next_actions: [`Did you mean another table? Run "miaoda db schema list" to see all tables.`],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (err.code === 'DB_API_k_dl_1300040') {
|
|
320
|
+
const t = tables[0] ?? '<table>';
|
|
321
|
+
return new error_1.AppError(err.code, err.message, {
|
|
322
|
+
next_actions: [`Enable with: miaoda db audit enable ${t}`],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (err.code === 'DB_API_k_dl_1300041') {
|
|
326
|
+
return new error_1.AppError(err.code, err.message, {
|
|
327
|
+
next_actions: ['Check audit status with: miaoda db audit status'],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return err;
|
|
331
|
+
}
|
|
332
|
+
// extractMissingTable 从 dataloom 原始文案里抠表名兜底。dataloom 端有两种格式:
|
|
333
|
+
// - 旧版:`Table [foo] doesn't exist` / `table [foo] not exist`
|
|
334
|
+
// - 新版(本次 commits):`table foo not found`
|
|
335
|
+
// 都失配时返 null,调用方走 `<table>` 占位。
|
|
336
|
+
function extractMissingTable(msg) {
|
|
337
|
+
const bracket = /\[([^\]]+)\]/.exec(msg);
|
|
338
|
+
if (bracket)
|
|
339
|
+
return bracket[1];
|
|
340
|
+
const m = /table\s+([\w.]+)\s+not (?:exist|found)/i.exec(msg);
|
|
341
|
+
return m ? m[1] : null;
|
|
342
|
+
}
|
|
281
343
|
// 服务端 before/after 是 JSON 字符串透传,CLI JSON 输出再反序列化回对象,
|
|
282
344
|
// 给下游 jq 等工具消费。失败时透传原始字符串避免数据丢失。
|
|
283
345
|
function safeParseJson(s) {
|
|
@@ -40,11 +40,12 @@ const error_1 = require("../../../utils/error");
|
|
|
40
40
|
const output_1 = require("../../../utils/output");
|
|
41
41
|
const render_1 = require("../../../utils/render");
|
|
42
42
|
const time_1 = require("../../../utils/time");
|
|
43
|
+
const _operator_1 = require("../../../cli/handlers/db/_operator");
|
|
43
44
|
function toRow(it) {
|
|
44
45
|
return {
|
|
45
46
|
change_id: it.changeId,
|
|
46
47
|
changed_at: it.changedAt,
|
|
47
|
-
operator: it.operator,
|
|
48
|
+
operator: (0, _operator_1.parseOperator)(it.operator),
|
|
48
49
|
target_table: it.targetTable,
|
|
49
50
|
change_type: it.changeType,
|
|
50
51
|
summary: it.summary,
|
|
@@ -115,10 +116,11 @@ async function handleDbChangelog(opts) {
|
|
|
115
116
|
const tty = (0, render_1.isStdoutTty)();
|
|
116
117
|
// PRD: change_id / changed_at / operator / target_table / change_type / summary
|
|
117
118
|
const headers = ['change_id', 'changed_at', 'operator', 'target_table', 'change_type', 'summary'];
|
|
119
|
+
// pretty 渲染只展示 operator.name(兼容 PRD 原 string 形态);--json 走 toRow 输出完整对象
|
|
118
120
|
const rows = allItems.map((it) => [
|
|
119
121
|
it.changeId,
|
|
120
122
|
(0, render_1.formatTime)(it.changedAt, tty),
|
|
121
|
-
it.operator || '—',
|
|
123
|
+
(0, _operator_1.parseOperator)(it.operator).name || '—',
|
|
122
124
|
it.targetTable || '—',
|
|
123
125
|
it.changeType,
|
|
124
126
|
it.summary || '—',
|
|
@@ -61,15 +61,11 @@ async function handleDbRecoveryDiff(target, opts) {
|
|
|
61
61
|
async function handleDbRecoveryApply(target, opts) {
|
|
62
62
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
63
63
|
const ts = normalizeTimestamp(target);
|
|
64
|
-
// PRD 要求 apply 输出包含 N tables affected / Ms elapsed
|
|
65
|
-
//
|
|
66
|
-
//
|
|
64
|
+
// PRD 要求 apply 输出包含 N tables affected / Ms elapsed。
|
|
65
|
+
// tables_affected 从 preview 拿;elapsed 用 CLI 端墙钟(dataloom apply 是异步
|
|
66
|
+
// 触发,server-side elapsed=0;下面 poll 真终态后用本地计时器算 elapsed)。
|
|
67
67
|
const preview = await runRecoveryPreview(appId, ts);
|
|
68
68
|
const tablesAffected = preview.tablesAffected ?? preview.changes?.length ?? 0;
|
|
69
|
-
// dataloom 端 apply 是异步触发,elapsed_seconds 返 0。这里改用 preview 给出的
|
|
70
|
-
// estimated_seconds 当 "预计耗时",更贴近 PRD 示例的 30s elapsed 含义;
|
|
71
|
-
// 都没填时按 PRD 默认值 30 兜底。
|
|
72
|
-
const elapsedFromPreview = preview.estimatedSeconds ?? 30;
|
|
73
69
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
74
70
|
renderDiff(ts, preview);
|
|
75
71
|
const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
|
|
@@ -85,7 +81,12 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
85
81
|
catch (err) {
|
|
86
82
|
throw decorateRecoveryError(err);
|
|
87
83
|
}
|
|
88
|
-
|
|
84
|
+
// 关键:墙钟从 apply 触发瞬间开始,poll 真完成时停。dataloom restore 是异步触发,
|
|
85
|
+
// 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
|
|
86
|
+
// Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
await waitRecoveryDone(appId, ts);
|
|
89
|
+
const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
89
90
|
if ((0, output_1.isJsonMode)()) {
|
|
90
91
|
// PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
|
|
91
92
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -101,6 +102,37 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
101
102
|
`(${String(tablesAffected)} tables affected, ${String(elapsed)}s elapsed)`;
|
|
102
103
|
(0, output_1.emit)(tty ? colors_1.c.success(`✓ ${body}`) : `OK ${body}`);
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* 触发 BranchRestore 后 poll 直到任务终态。dataloom 端 Redis 缓存 workspace 级
|
|
107
|
+
* status,归一成 running / success / failed:
|
|
108
|
+
* - running → 继续 poll
|
|
109
|
+
* - success → 退出,CLI 渲染 ✓
|
|
110
|
+
* - failed → 抛 DB_API_k_dl_1300036 + errorMessage
|
|
111
|
+
*
|
|
112
|
+
* 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
|
|
113
|
+
* 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
|
|
114
|
+
*/
|
|
115
|
+
async function waitRecoveryDone(appId, target) {
|
|
116
|
+
try {
|
|
117
|
+
return await (0, poll_1.pollUntilDone)({
|
|
118
|
+
label: 'recovery apply',
|
|
119
|
+
intervalMs: 2000,
|
|
120
|
+
fetch: () => api.db.getRecoveryStatus({ appId }),
|
|
121
|
+
isDone: (cur) => {
|
|
122
|
+
const status = (cur.status || '').toLowerCase();
|
|
123
|
+
if (status === 'success')
|
|
124
|
+
return { done: true, value: cur };
|
|
125
|
+
if (status === 'failed') {
|
|
126
|
+
throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? `recovery to ${target} failed`);
|
|
127
|
+
}
|
|
128
|
+
return { done: false };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
throw decorateRecoveryError(err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
104
136
|
/**
|
|
105
137
|
* 触发 PITR 预览任务并轮询到终态:
|
|
106
138
|
* 1. POST /db/recovery dryRun=true → previewRequestId
|
|
@@ -171,20 +203,20 @@ function normalizeTimestamp(input) {
|
|
|
171
203
|
}
|
|
172
204
|
// PRD diff 输出,三套:
|
|
173
205
|
//
|
|
174
|
-
// TTY pretty(缩进 prose、带 Unicode
|
|
206
|
+
// TTY pretty(缩进 prose、带 Unicode 箭头、表名按列对齐):
|
|
175
207
|
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
176
208
|
//
|
|
177
209
|
// tables affected: 2
|
|
178
|
-
// users: +3 rows, -1 row
|
|
179
|
-
// orders: table will be restored
|
|
210
|
+
// users: +3 rows, -1 row
|
|
211
|
+
// orders: table will be restored
|
|
180
212
|
//
|
|
181
213
|
// estimated time: ~30s
|
|
182
214
|
//
|
|
183
215
|
// non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
|
|
184
216
|
// Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
185
217
|
// tables_affected\t2
|
|
186
|
-
// users\t+3 rows, -1 row
|
|
187
|
-
// orders\ttable will be restored
|
|
218
|
+
// users\t+3 rows, -1 row
|
|
219
|
+
// orders\ttable will be restored
|
|
188
220
|
// estimated_time\t30
|
|
189
221
|
//
|
|
190
222
|
// --json:标准 envelope,字段名固定 snake_case。
|
|
@@ -200,7 +232,6 @@ function renderDiff(target, preview) {
|
|
|
200
232
|
table: c.table,
|
|
201
233
|
inserted: c.inserted,
|
|
202
234
|
deleted: c.deleted,
|
|
203
|
-
modified: c.modified,
|
|
204
235
|
action: c.action,
|
|
205
236
|
droppedAt: c.droppedAt,
|
|
206
237
|
})),
|
|
@@ -250,51 +281,31 @@ function renderDiffPipe(target, changes, tablesAffected, estimated) {
|
|
|
250
281
|
return lines.join('\n');
|
|
251
282
|
}
|
|
252
283
|
function describeChange(c) {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
284
|
+
// dataloom 端 action 是 PRD 三态 + unavailable 边界:
|
|
285
|
+
// restore_table — schema diff 显示该表在目标时间点存在但当前没有
|
|
286
|
+
// drop_table — 该表当前有但目标时间点没有
|
|
287
|
+
// alter_table — 两侧都在但结构有差异(列 / 索引 / 关系等)
|
|
288
|
+
// unavailable — PITR diff 算不出来,droppedAt 字段复用透传 message
|
|
289
|
+
// 没 action 时是数据行数变化,走下面的 +N / -N 渲染。
|
|
255
290
|
if (c.action === 'restore_table') {
|
|
256
|
-
|
|
257
|
-
return `table will be restored${ts}`;
|
|
291
|
+
return 'table will be restored';
|
|
258
292
|
}
|
|
259
|
-
if (c.action === '
|
|
260
|
-
|
|
261
|
-
return `table will be dropped${ts}`;
|
|
293
|
+
if (c.action === 'drop_table') {
|
|
294
|
+
return 'table will be dropped';
|
|
262
295
|
}
|
|
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
|
-
}
|
|
296
|
+
if (c.action === 'alter_table') {
|
|
297
|
+
return 'table will be altered';
|
|
284
298
|
}
|
|
285
299
|
if (c.action === 'unavailable') {
|
|
286
|
-
// dataloom 端 count 失败的表,复用 droppedAt 透传 message
|
|
287
300
|
return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== '' ? `: ${c.droppedAt}` : ''}`;
|
|
288
301
|
}
|
|
289
|
-
// 数据变更:+N rows / -N rows
|
|
302
|
+
// 数据变更:+N rows / -N rows
|
|
290
303
|
const parts = [];
|
|
291
304
|
if (c.inserted !== undefined && c.inserted !== 0)
|
|
292
305
|
parts.push(`+${String(c.inserted)} rows`);
|
|
293
306
|
if (c.deleted !== undefined && c.deleted !== 0) {
|
|
294
307
|
parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? 'row' : 'rows'}`);
|
|
295
308
|
}
|
|
296
|
-
if (c.modified !== undefined && c.modified !== 0)
|
|
297
|
-
parts.push(`~${String(c.modified)} rows modified`);
|
|
298
309
|
return parts.length === 0 ? 'no changes' : parts.join(', ');
|
|
299
310
|
}
|
|
300
311
|
/**
|