@lark-apaas/miaoda-cli 0.1.3-alpha.2a09432 → 0.1.3-alpha.2d526ac
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 +31 -3
- package/dist/api/db/client.js +6 -2
- package/dist/api/db/index.js +2 -1
- package/dist/cli/commands/db/index.js +1 -0
- package/dist/cli/commands/file/index.js +1 -1
- package/dist/cli/handlers/db/audit.js +92 -13
- package/dist/cli/handlers/db/changelog.js +33 -5
- package/dist/cli/handlers/db/migration.js +10 -0
- package/dist/cli/handlers/db/recovery.js +76 -46
- 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");
|
|
@@ -217,7 +218,8 @@ async function importData(opts) {
|
|
|
217
218
|
records: opts.body.toString('utf8'),
|
|
218
219
|
};
|
|
219
220
|
if (opts.dbBranch !== undefined && opts.dbBranch !== '') {
|
|
220
|
-
|
|
221
|
+
// 兼容 `online` 别名 → 后端实际 dbBranch 名为 `main`
|
|
222
|
+
reqBody.dbBranch = opts.dbBranch === 'online' ? 'main' : opts.dbBranch;
|
|
221
223
|
}
|
|
222
224
|
const start = Date.now();
|
|
223
225
|
let response;
|
|
@@ -325,6 +327,7 @@ async function listDDLChangelog(opts) {
|
|
|
325
327
|
table: opts.table,
|
|
326
328
|
since: opts.since,
|
|
327
329
|
until: opts.until,
|
|
330
|
+
changeId: opts.changeId,
|
|
328
331
|
limit: opts.limit !== undefined ? String(opts.limit) : undefined,
|
|
329
332
|
cursor: opts.cursor,
|
|
330
333
|
dbBranch: opts.dbBranch,
|
|
@@ -387,8 +390,10 @@ async function setAuditConfig(opts) {
|
|
|
387
390
|
};
|
|
388
391
|
if (opts.retention !== undefined && opts.retention !== '')
|
|
389
392
|
body.retention = opts.retention;
|
|
390
|
-
if (opts.dbBranch !== undefined && opts.dbBranch !== '')
|
|
391
|
-
|
|
393
|
+
if (opts.dbBranch !== undefined && opts.dbBranch !== '') {
|
|
394
|
+
// 兼容 `online` 别名 → 后端实际 dbBranch 名为 `main`
|
|
395
|
+
body.dbBranch = opts.dbBranch === 'online' ? 'main' : opts.dbBranch;
|
|
396
|
+
}
|
|
392
397
|
const start = Date.now();
|
|
393
398
|
let response;
|
|
394
399
|
try {
|
|
@@ -572,6 +577,29 @@ async function getRecoveryPreview(opts) {
|
|
|
572
577
|
const body = (await response.json());
|
|
573
578
|
return (0, client_1.extractData)(body);
|
|
574
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/recovery/status
|
|
582
|
+
* CLI apply 触发后定时调本接口直到 status=success/failed。dataloom 内部 Redis
|
|
583
|
+
* 维护 workspace 级 restore 状态,无需传 task id;workspace+dbBranch 维度同时
|
|
584
|
+
* 只允许一个 restore 进行中。
|
|
585
|
+
*/
|
|
586
|
+
async function getRecoveryStatus(opts) {
|
|
587
|
+
const client = (0, http_1.getHttpClient)();
|
|
588
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, '/db/recovery/status', { dbBranch: opts.dbBranch });
|
|
589
|
+
const start = Date.now();
|
|
590
|
+
let response;
|
|
591
|
+
try {
|
|
592
|
+
response = await client.get(url);
|
|
593
|
+
(0, client_1.traceHttp)('GET', url, start, response);
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
(0, client_1.traceHttp)('GET', url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
597
|
+
await mapDbHttpError(err, url, 'Failed to get recovery status');
|
|
598
|
+
throw err; // 不可达
|
|
599
|
+
}
|
|
600
|
+
const body = (await response.json());
|
|
601
|
+
return (0, client_1.extractData)(body);
|
|
602
|
+
}
|
|
575
603
|
// ── db quota → InnerAdminGetDbQuota ──
|
|
576
604
|
/**
|
|
577
605
|
* 后端:GET /v1/dataloom/app/{appId}/db/quota?dbBranch=
|
package/dist/api/db/client.js
CHANGED
|
@@ -202,8 +202,12 @@ function buildInnerUrl(appId, path, query) {
|
|
|
202
202
|
if (query) {
|
|
203
203
|
const usp = new URLSearchParams();
|
|
204
204
|
for (const [k, v] of Object.entries(query)) {
|
|
205
|
-
if (v
|
|
206
|
-
|
|
205
|
+
if (v === undefined || v === '')
|
|
206
|
+
continue;
|
|
207
|
+
// dbBranch 兼容用户视角的 `online` 别名 → 后端实际 dbBranch 名为 `main`,
|
|
208
|
+
// 这里集中归一,避免每个 API 函数各自处理。其他值(dev / 自定义分支)原样透传。
|
|
209
|
+
const norm = k === 'dbBranch' && v === 'online' ? 'main' : v;
|
|
210
|
+
usp.append(k, norm);
|
|
207
211
|
}
|
|
208
212
|
const qs = usp.toString();
|
|
209
213
|
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; } });
|
|
@@ -224,6 +224,7 @@ Examples:
|
|
|
224
224
|
.option('--table <name>', '按表名过滤')
|
|
225
225
|
.option('--since <time>', '起始时间')
|
|
226
226
|
.option('--until <time>', '截止时间')
|
|
227
|
+
.option('--change-id <id>', '按 change_id 精确查询单条变更记录(指定后只返该 ID 一条,JSON 仍保持数组)')
|
|
227
228
|
.option('--limit <n>', '返回条数上限(默认 20)', parsePositiveInt, 20)
|
|
228
229
|
.option('--cursor <token>', '从上一页返回的游标位置继续获取')
|
|
229
230
|
.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)
|
|
@@ -199,17 +199,48 @@ async function handleDbAuditList(tables, opts) {
|
|
|
199
199
|
// 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
|
|
200
200
|
const since = normalizeTime(opts.since, '--since');
|
|
201
201
|
const until = normalizeTime(opts.until, '--until');
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
// 多表场景:dataloom 对"任一表不存在"是 fail-fast(typo 应立即可见,符合 PRD 单表语义),
|
|
203
|
+
// 但 PRD 多表语义是"其他表继续返回,底部汇总跳过情况"。CLI 在这里做闭环:把不存在的表
|
|
204
|
+
// 从入参里剥掉重试,最终跟服务端 skipped(audit 未启用)合并展示。单表场景仍 fail-fast。
|
|
205
|
+
const isMulti = tables.length > 1;
|
|
206
|
+
let result;
|
|
207
|
+
const localMissing = [];
|
|
208
|
+
let curTables = [...tables];
|
|
209
|
+
for (;;) {
|
|
210
|
+
try {
|
|
211
|
+
result = await api.db.listAuditLog({
|
|
212
|
+
appId,
|
|
213
|
+
tables: curTables,
|
|
214
|
+
since,
|
|
215
|
+
until,
|
|
216
|
+
limit,
|
|
217
|
+
cursor: opts.cursor,
|
|
218
|
+
dbBranch: opts.env,
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (isMulti && err instanceof error_1.AppError && err.code === 'DB_API_k_dl_000005') {
|
|
224
|
+
const missing = extractMissingTable(err.message);
|
|
225
|
+
if (missing !== null && curTables.includes(missing)) {
|
|
226
|
+
localMissing.push(missing);
|
|
227
|
+
curTables = curTables.filter((t) => t !== missing);
|
|
228
|
+
if (curTables.length === 0) {
|
|
229
|
+
// 全部表都不存在 → 抛 TABLE_NOT_FOUND,列出所有缺失表
|
|
230
|
+
throw new error_1.AppError('TABLE_NOT_FOUND', `None of the requested tables exist: ${localMissing.join(', ')}`, {
|
|
231
|
+
next_actions: ['Run `miaoda db schema list` to see all tables.'],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw decorateAuditListError(err, tables);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
211
240
|
const visible = result.items;
|
|
212
|
-
|
|
241
|
+
// 服务端 skipped(audit 未启用,bare 名)+ CLI 本地探测 missing 合并;
|
|
242
|
+
// localMissing 自带 `(table not found)` 后缀,formatSkippedHint 透传渲染
|
|
243
|
+
const skipped = [...result.skipped, ...localMissing.map((t) => `${t} (table not found)`)];
|
|
213
244
|
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
214
245
|
// operator 后端用 JSON 字符串内嵌 {id, name},--json 输出还原成对象供下游消费
|
|
215
246
|
if ((0, output_1.isJsonMode)()) {
|
|
@@ -226,7 +257,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
226
257
|
}));
|
|
227
258
|
(0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
|
|
228
259
|
if (skipped.length > 0) {
|
|
229
|
-
process.stderr.write(
|
|
260
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
230
261
|
}
|
|
231
262
|
if (visible.length === 0)
|
|
232
263
|
process.exitCode = 1;
|
|
@@ -235,7 +266,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
235
266
|
if (visible.length === 0) {
|
|
236
267
|
(0, output_1.emit)('No audit log entries found.');
|
|
237
268
|
if (skipped.length > 0) {
|
|
238
|
-
process.stderr.write(
|
|
269
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
239
270
|
}
|
|
240
271
|
process.exitCode = 1;
|
|
241
272
|
return;
|
|
@@ -261,7 +292,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
261
292
|
});
|
|
262
293
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
263
294
|
if (skipped.length > 0) {
|
|
264
|
-
process.stderr.write(
|
|
295
|
+
process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
|
|
265
296
|
}
|
|
266
297
|
if (result.hasMore && result.nextCursor) {
|
|
267
298
|
process.stderr.write(`(more results; use --cursor '${result.nextCursor}')\n`);
|
|
@@ -287,6 +318,54 @@ function normalizeTime(input, flagName) {
|
|
|
287
318
|
throw err;
|
|
288
319
|
}
|
|
289
320
|
}
|
|
321
|
+
// PRD 41-48 行格式:`— Skipped 1 of 3 tables: orders (audit not enabled)`
|
|
322
|
+
// 服务端 skipped 数组是 audit 未启用的表(bare 名);上面 peel 循环拼出来的
|
|
323
|
+
// "<name> (table not found)" 已经自带 reason,t.includes('(') 走原样透传,bare 名
|
|
324
|
+
// 默认拼 "(audit not enabled)"。也兼容未来后端直接给带 reason 字符串的可能。
|
|
325
|
+
function formatSkippedHint(skipped, totalRequested) {
|
|
326
|
+
const items = skipped.map((t) => (t.includes('(') ? t : `${t} (audit not enabled)`)).join(', ');
|
|
327
|
+
return `— Skipped ${String(skipped.length)} of ${String(totalRequested)} tables: ${items}`;
|
|
328
|
+
}
|
|
329
|
+
// audit list 后端错误码加 hint:
|
|
330
|
+
// - k_dl_000005 表不存在 → 文案与 hint 对齐 `db schema get` 的统一格式
|
|
331
|
+
// - k_dl_1300040 单表 audit 未启用 → 指引 enable
|
|
332
|
+
// - k_dl_1300041 多表全部未启用 → 指引 status
|
|
333
|
+
function decorateAuditListError(err, tables) {
|
|
334
|
+
if (!(err instanceof error_1.AppError))
|
|
335
|
+
return err;
|
|
336
|
+
if (err.code === 'DB_API_k_dl_000005') {
|
|
337
|
+
// 不复用 dataloom 透传的 message(旧版/新版文案不一致:`table [x] not exist` /
|
|
338
|
+
// `table x not found`),统一改写成跟 schema get 一样的 `Table '<name>' does not exist`,
|
|
339
|
+
// 单表传 tables[0],多表场景兜底用解析到的 raw message 里第一个表名。
|
|
340
|
+
const t = tables.length > 0 ? tables[0] : (extractMissingTable(err.message) ?? '<table>');
|
|
341
|
+
return new error_1.AppError('TABLE_NOT_FOUND', `Table '${t}' does not exist`, {
|
|
342
|
+
next_actions: [`Did you mean another table? Run "miaoda db schema list" to see all tables.`],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (err.code === 'DB_API_k_dl_1300040') {
|
|
346
|
+
const t = tables[0] ?? '<table>';
|
|
347
|
+
return new error_1.AppError(err.code, err.message, {
|
|
348
|
+
next_actions: [`Enable with: miaoda db audit enable ${t}`],
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (err.code === 'DB_API_k_dl_1300041') {
|
|
352
|
+
return new error_1.AppError(err.code, err.message, {
|
|
353
|
+
next_actions: ['Check audit status with: miaoda db audit status'],
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return err;
|
|
357
|
+
}
|
|
358
|
+
// extractMissingTable 从 dataloom 原始文案里抠表名兜底。dataloom 端有两种格式:
|
|
359
|
+
// - 旧版:`Table [foo] doesn't exist` / `table [foo] not exist`
|
|
360
|
+
// - 新版(本次 commits):`table foo not found`
|
|
361
|
+
// 都失配时返 null,调用方走 `<table>` 占位。
|
|
362
|
+
function extractMissingTable(msg) {
|
|
363
|
+
const bracket = /\[([^\]]+)\]/.exec(msg);
|
|
364
|
+
if (bracket)
|
|
365
|
+
return bracket[1];
|
|
366
|
+
const m = /table\s+([\w.]+)\s+not (?:exist|found)/i.exec(msg);
|
|
367
|
+
return m ? m[1] : null;
|
|
368
|
+
}
|
|
290
369
|
// 服务端 before/after 是 JSON 字符串透传,CLI JSON 输出再反序列化回对象,
|
|
291
370
|
// 给下游 jq 等工具消费。失败时透传原始字符串避免数据丢失。
|
|
292
371
|
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
|
}
|
|
@@ -47,6 +47,7 @@ const error_1 = require("../../../utils/error");
|
|
|
47
47
|
const output_1 = require("../../../utils/output");
|
|
48
48
|
const poll_1 = require("../../../utils/poll");
|
|
49
49
|
const render_1 = require("../../../utils/render");
|
|
50
|
+
const spinner_1 = require("../../../utils/spinner");
|
|
50
51
|
async function handleDbMigrationInit(opts) {
|
|
51
52
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
52
53
|
// 不可逆操作,TTY 默认要求 y/N;--yes 跳过
|
|
@@ -88,12 +89,16 @@ async function handleDbMigrationInit(opts) {
|
|
|
88
89
|
async function handleDbMigrationDiff(opts) {
|
|
89
90
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
90
91
|
let result;
|
|
92
|
+
const stopSpinner = (0, spinner_1.startSpinner)('Computing migration diff');
|
|
91
93
|
try {
|
|
92
94
|
result = await api.db.migrate({ appId, dryRun: true });
|
|
93
95
|
}
|
|
94
96
|
catch (err) {
|
|
95
97
|
throw decorateMigrationError(err);
|
|
96
98
|
}
|
|
99
|
+
finally {
|
|
100
|
+
stopSpinner();
|
|
101
|
+
}
|
|
97
102
|
// PRD: diff 在无待发布变更时报错带 hint,而不是渲染空列表
|
|
98
103
|
if (result.changes.length === 0) {
|
|
99
104
|
throw new error_1.AppError('DB_API_k_dl_1300035', `No pending changes between ${result.from} and ${result.to}`, {
|
|
@@ -109,12 +114,16 @@ async function handleDbMigrationApply(opts) {
|
|
|
109
114
|
// TTY 下先 diff 给用户审;--yes 直接打到 online
|
|
110
115
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
111
116
|
let preview;
|
|
117
|
+
const stopSpinner = (0, spinner_1.startSpinner)('Computing migration diff');
|
|
112
118
|
try {
|
|
113
119
|
preview = await api.db.migrate({ appId, dryRun: true });
|
|
114
120
|
}
|
|
115
121
|
catch (err) {
|
|
116
122
|
throw decorateMigrationError(err);
|
|
117
123
|
}
|
|
124
|
+
finally {
|
|
125
|
+
stopSpinner();
|
|
126
|
+
}
|
|
118
127
|
if (preview.changes.length === 0) {
|
|
119
128
|
// PRD 文案 + hint
|
|
120
129
|
throw new error_1.AppError('DB_API_k_dl_1300035', `No pending changes between ${preview.from} and ${preview.to}`, {
|
|
@@ -145,6 +154,7 @@ async function handleDbMigrationApply(opts) {
|
|
|
145
154
|
const taskId = result.taskId;
|
|
146
155
|
const final = await (0, poll_1.pollUntilDone)({
|
|
147
156
|
label: 'migration apply',
|
|
157
|
+
spinnerLabel: 'Applying migration to online',
|
|
148
158
|
intervalMs: 1000,
|
|
149
159
|
fetch: () => api.db.getMigrationStatus({ appId, taskId }),
|
|
150
160
|
isDone: (cur) => {
|
|
@@ -61,15 +61,28 @@ 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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
69
|
+
// 0 changes 短路:目标时间点与当前一致,apply 没意义。pretty 模式渲染 preview
|
|
70
|
+
// 的 "No changes — database is already at this state." 后直接退;--json 模式
|
|
71
|
+
// 返 status="no_changes" envelope 让下游识别。不进 confirm 也不下发 apply。
|
|
72
|
+
if ((preview.changes?.length ?? 0) === 0) {
|
|
73
|
+
if ((0, output_1.isJsonMode)()) {
|
|
74
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
75
|
+
status: 'no_changes',
|
|
76
|
+
target: ts,
|
|
77
|
+
tablesAffected: 0,
|
|
78
|
+
elapsedSeconds: 0,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
renderDiff(ts, preview);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
73
86
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
74
87
|
renderDiff(ts, preview);
|
|
75
88
|
const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
|
|
@@ -85,7 +98,12 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
85
98
|
catch (err) {
|
|
86
99
|
throw decorateRecoveryError(err);
|
|
87
100
|
}
|
|
88
|
-
|
|
101
|
+
// 关键:墙钟从 apply 触发瞬间开始,poll 真完成时停。dataloom restore 是异步触发,
|
|
102
|
+
// 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
|
|
103
|
+
// Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
await waitRecoveryDone(appId, ts);
|
|
106
|
+
const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
89
107
|
if ((0, output_1.isJsonMode)()) {
|
|
90
108
|
// PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
|
|
91
109
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -101,6 +119,38 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
101
119
|
`(${String(tablesAffected)} tables affected, ${String(elapsed)}s elapsed)`;
|
|
102
120
|
(0, output_1.emit)(tty ? colors_1.c.success(`✓ ${body}`) : `OK ${body}`);
|
|
103
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* 触发 BranchRestore 后 poll 直到任务终态。dataloom 端 Redis 缓存 workspace 级
|
|
124
|
+
* status,归一成 running / success / failed:
|
|
125
|
+
* - running → 继续 poll
|
|
126
|
+
* - success → 退出,CLI 渲染 ✓
|
|
127
|
+
* - failed → 抛 DB_API_k_dl_1300036 + errorMessage
|
|
128
|
+
*
|
|
129
|
+
* 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
|
|
130
|
+
* 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
|
|
131
|
+
*/
|
|
132
|
+
async function waitRecoveryDone(appId, target) {
|
|
133
|
+
try {
|
|
134
|
+
return await (0, poll_1.pollUntilDone)({
|
|
135
|
+
label: 'recovery apply',
|
|
136
|
+
spinnerLabel: 'Restoring database to target time',
|
|
137
|
+
intervalMs: 2000,
|
|
138
|
+
fetch: () => api.db.getRecoveryStatus({ appId }),
|
|
139
|
+
isDone: (cur) => {
|
|
140
|
+
const status = (cur.status || '').toLowerCase();
|
|
141
|
+
if (status === 'success')
|
|
142
|
+
return { done: true, value: cur };
|
|
143
|
+
if (status === 'failed') {
|
|
144
|
+
throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? `recovery to ${target} failed`);
|
|
145
|
+
}
|
|
146
|
+
return { done: false };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
throw decorateRecoveryError(err);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
104
154
|
/**
|
|
105
155
|
* 触发 PITR 预览任务并轮询到终态:
|
|
106
156
|
* 1. POST /db/recovery dryRun=true → previewRequestId
|
|
@@ -125,6 +175,7 @@ async function runRecoveryPreview(appId, ts) {
|
|
|
125
175
|
try {
|
|
126
176
|
return await (0, poll_1.pollUntilDone)({
|
|
127
177
|
label: 'recovery preview',
|
|
178
|
+
spinnerLabel: 'Computing recovery preview',
|
|
128
179
|
intervalMs: 1000,
|
|
129
180
|
fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
|
|
130
181
|
isDone: (cur) => {
|
|
@@ -171,20 +222,20 @@ function normalizeTimestamp(input) {
|
|
|
171
222
|
}
|
|
172
223
|
// PRD diff 输出,三套:
|
|
173
224
|
//
|
|
174
|
-
// TTY pretty(缩进 prose、带 Unicode
|
|
225
|
+
// TTY pretty(缩进 prose、带 Unicode 箭头、表名按列对齐):
|
|
175
226
|
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
176
227
|
//
|
|
177
228
|
// tables affected: 2
|
|
178
|
-
// users: +3 rows, -1 row
|
|
179
|
-
// orders: table will be restored
|
|
229
|
+
// users: +3 rows, -1 row
|
|
230
|
+
// orders: table will be restored
|
|
180
231
|
//
|
|
181
232
|
// estimated time: ~30s
|
|
182
233
|
//
|
|
183
234
|
// non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
|
|
184
235
|
// Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
185
236
|
// tables_affected\t2
|
|
186
|
-
// users\t+3 rows, -1 row
|
|
187
|
-
// orders\ttable will be restored
|
|
237
|
+
// users\t+3 rows, -1 row
|
|
238
|
+
// orders\ttable will be restored
|
|
188
239
|
// estimated_time\t30
|
|
189
240
|
//
|
|
190
241
|
// --json:标准 envelope,字段名固定 snake_case。
|
|
@@ -200,7 +251,6 @@ function renderDiff(target, preview) {
|
|
|
200
251
|
table: c.table,
|
|
201
252
|
inserted: c.inserted,
|
|
202
253
|
deleted: c.deleted,
|
|
203
|
-
modified: c.modified,
|
|
204
254
|
action: c.action,
|
|
205
255
|
droppedAt: c.droppedAt,
|
|
206
256
|
})),
|
|
@@ -250,51 +300,31 @@ function renderDiffPipe(target, changes, tablesAffected, estimated) {
|
|
|
250
300
|
return lines.join('\n');
|
|
251
301
|
}
|
|
252
302
|
function describeChange(c) {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
303
|
+
// dataloom 端 action 是 PRD 三态 + unavailable 边界:
|
|
304
|
+
// restore_table — schema diff 显示该表在目标时间点存在但当前没有
|
|
305
|
+
// drop_table — 该表当前有但目标时间点没有
|
|
306
|
+
// alter_table — 两侧都在但结构有差异(列 / 索引 / 关系等)
|
|
307
|
+
// unavailable — PITR diff 算不出来,droppedAt 字段复用透传 message
|
|
308
|
+
// 没 action 时是数据行数变化,走下面的 +N / -N 渲染。
|
|
255
309
|
if (c.action === 'restore_table') {
|
|
256
|
-
|
|
257
|
-
return `table will be restored${ts}`;
|
|
258
|
-
}
|
|
259
|
-
if (c.action === 'drop') {
|
|
260
|
-
const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : '';
|
|
261
|
-
return `table will be dropped${ts}`;
|
|
310
|
+
return 'table will be restored';
|
|
262
311
|
}
|
|
263
|
-
if (c.action === '
|
|
264
|
-
|
|
265
|
-
return `table will be created${ts}`;
|
|
312
|
+
if (c.action === 'drop_table') {
|
|
313
|
+
return 'table will be dropped';
|
|
266
314
|
}
|
|
267
|
-
if (c.action
|
|
268
|
-
|
|
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
|
-
}
|
|
315
|
+
if (c.action === 'alter_table') {
|
|
316
|
+
return 'table will be altered';
|
|
284
317
|
}
|
|
285
318
|
if (c.action === 'unavailable') {
|
|
286
|
-
// dataloom 端 count 失败的表,复用 droppedAt 透传 message
|
|
287
319
|
return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== '' ? `: ${c.droppedAt}` : ''}`;
|
|
288
320
|
}
|
|
289
|
-
// 数据变更:+N rows / -N rows
|
|
321
|
+
// 数据变更:+N rows / -N rows
|
|
290
322
|
const parts = [];
|
|
291
323
|
if (c.inserted !== undefined && c.inserted !== 0)
|
|
292
324
|
parts.push(`+${String(c.inserted)} rows`);
|
|
293
325
|
if (c.deleted !== undefined && c.deleted !== 0) {
|
|
294
326
|
parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? 'row' : 'rows'}`);
|
|
295
327
|
}
|
|
296
|
-
if (c.modified !== undefined && c.modified !== 0)
|
|
297
|
-
parts.push(`~${String(c.modified)} rows modified`);
|
|
298
328
|
return parts.length === 0 ? 'no changes' : parts.join(', ');
|
|
299
329
|
}
|
|
300
330
|
/**
|
|
@@ -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
|
+
}
|