@lark-apaas/miaoda-cli 0.1.3-alpha.a25d692 → 0.1.3-alpha.a7a2f65

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.
@@ -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
- reqBody.dbBranch = opts.dbBranch;
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
- body.dbBranch = opts.dbBranch;
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=
@@ -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 !== undefined && v !== '')
206
- usp.append(k, v);
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)
@@ -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; } });
@@ -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
  }
@@ -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
- ? Delete '/images/brand/1858537546760216.png'? (y/N) y
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)
@@ -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,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,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,8 @@ 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");
48
+ const index_1 = require("../../../api/db/index");
47
49
  const VALID_RETENTION = new Set(['7d', '30d', '180d', '360d', 'forever']);
48
50
  async function handleDbAuditStatus(table, opts) {
49
51
  const appId = (0, shared_1.resolveAppId)(opts);
@@ -198,25 +200,57 @@ async function handleDbAuditList(tables, opts) {
198
200
  // 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
199
201
  const since = normalizeTime(opts.since, '--since');
200
202
  const until = normalizeTime(opts.until, '--until');
201
- const result = await api.db.listAuditLog({
202
- appId,
203
- tables,
204
- since,
205
- until,
206
- limit,
207
- cursor: opts.cursor,
208
- dbBranch: opts.env,
209
- });
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
+ }
210
241
  const visible = result.items;
211
- const skipped = result.skipped;
242
+ // 服务端 skipped(audit 未启用,bare 名)+ CLI 本地探测 missing 合并;
243
+ // localMissing 自带 `(table not found)` 后缀,formatSkippedHint 透传渲染
244
+ const skipped = [...result.skipped, ...localMissing.map((t) => `${t} (table not found)`)];
212
245
  // PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
246
+ // operator 后端用 JSON 字符串内嵌 {id, name},--json 输出还原成对象供下游消费
213
247
  if ((0, output_1.isJsonMode)()) {
214
248
  const items = visible.map((it) => ({
215
249
  event_id: it.eventId,
216
250
  event_time: it.eventTime,
217
251
  target_table: it.targetTable,
218
252
  type: it.type,
219
- operator: it.operator,
253
+ operator: (0, _operator_1.parseOperator)(it.operator),
220
254
  summary: it.summary,
221
255
  // before/after 服务端是 JSON 字符串,反序列化回结构化对象供下游消费
222
256
  ...(it.before !== undefined ? { before: safeParseJson(it.before) } : {}),
@@ -224,7 +258,7 @@ async function handleDbAuditList(tables, opts) {
224
258
  }));
225
259
  (0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
226
260
  if (skipped.length > 0) {
227
- process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(', ')})\n`);
261
+ process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
228
262
  }
229
263
  if (visible.length === 0)
230
264
  process.exitCode = 1;
@@ -233,7 +267,7 @@ async function handleDbAuditList(tables, opts) {
233
267
  if (visible.length === 0) {
234
268
  (0, output_1.emit)('No audit log entries found.');
235
269
  if (skipped.length > 0) {
236
- process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(', ')})\n`);
270
+ process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
237
271
  }
238
272
  process.exitCode = 1;
239
273
  return;
@@ -247,12 +281,19 @@ async function handleDbAuditList(tables, opts) {
247
281
  const rows = visible.map((it) => {
248
282
  const eventTime = (0, render_1.formatTime)(it.eventTime, tty);
249
283
  // event_id 完整透传——PRD 截图里的 "..." 只是文档省略写法,不是 CLI 行为
250
- const cells = [eventTime, it.type, it.eventId, it.operator || '—', it.summary];
284
+ // operator pretty 只展示 name;--json 上面已经走 parseOperator 输出 {id, name}
285
+ const cells = [
286
+ eventTime,
287
+ it.type,
288
+ it.eventId,
289
+ (0, _operator_1.parseOperator)(it.operator).name || '—',
290
+ it.summary,
291
+ ];
251
292
  return isMultiTable ? [it.targetTable, ...cells] : cells;
252
293
  });
253
294
  (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
254
295
  if (skipped.length > 0) {
255
- process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(', ')})\n`);
296
+ process.stderr.write(formatSkippedHint(skipped, tables.length) + '\n');
256
297
  }
257
298
  if (result.hasMore && result.nextCursor) {
258
299
  process.stderr.write(`(more results; use --cursor '${result.nextCursor}')\n`);
@@ -278,6 +319,54 @@ function normalizeTime(input, flagName) {
278
319
  throw err;
279
320
  }
280
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
+ }
281
370
  // 服务端 before/after 是 JSON 字符串透传,CLI JSON 输出再反序列化回对象,
282
371
  // 给下游 jq 等工具消费。失败时透传原始字符串避免数据丢失。
283
372
  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,
@@ -74,10 +75,17 @@ async function handleDbChangelog(opts) {
74
75
  const appId = (0, shared_1.resolveAppId)(opts);
75
76
  const since = normalizeTime(opts.since, '--since');
76
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;
77
82
  const allItems = [];
78
83
  let cursor = opts.cursor;
79
84
  let lastCursor = null;
80
85
  let lastHasMore = false;
86
+ // 指定 --change-id 时强制开 --all,确保即使后端不识别 changeId 过滤参数,
87
+ // CLI 也能翻完所有页找到目标记录(changelog 默认按时间倒序,一般在前几页)。
88
+ const allMode = opts.all === true || changeId !== undefined;
81
89
  // --all:循环到 hasMore=false;否则只拉一页
82
90
  // 没传 --all 时翻页由调用方自己用 --cursor 串
83
91
  for (;;) {
@@ -86,6 +94,7 @@ async function handleDbChangelog(opts) {
86
94
  table: opts.table,
87
95
  since,
88
96
  until,
97
+ changeId,
89
98
  limit: opts.limit,
90
99
  cursor,
91
100
  dbBranch: opts.env,
@@ -93,14 +102,28 @@ async function handleDbChangelog(opts) {
93
102
  allItems.push(...page.items);
94
103
  lastCursor = page.nextCursor;
95
104
  lastHasMore = page.hasMore;
96
- if (!opts.all || !page.hasMore || !page.nextCursor)
105
+ if (!allMode || !page.hasMore || !page.nextCursor)
97
106
  break;
98
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;
99
121
  }
100
122
  if ((0, output_1.isJsonMode)()) {
101
123
  const rows = allItems.map(toRow);
102
- // --all 时已经把所有页合一起返,has_more=false / next_cursor=null
103
- if (opts.all) {
124
+ // --all 或 --change-id 时已经把所有页合一起返,has_more=false / next_cursor=null
125
+ // PRD: --change-id 即使没命中(rows 长度 0)也保持 data 为数组。
126
+ if (allMode) {
104
127
  (0, output_1.emitPaged)((0, output_1.snakeCaseKeys)(rows), null, false);
105
128
  }
106
129
  else {
@@ -109,22 +132,29 @@ async function handleDbChangelog(opts) {
109
132
  return;
110
133
  }
111
134
  if (allItems.length === 0) {
112
- (0, output_1.emit)('No DDL changes found.');
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
+ }
113
142
  return;
114
143
  }
115
144
  const tty = (0, render_1.isStdoutTty)();
116
145
  // PRD: change_id / changed_at / operator / target_table / change_type / summary
117
146
  const headers = ['change_id', 'changed_at', 'operator', 'target_table', 'change_type', 'summary'];
147
+ // pretty 渲染只展示 operator.name(兼容 PRD 原 string 形态);--json 走 toRow 输出完整对象
118
148
  const rows = allItems.map((it) => [
119
149
  it.changeId,
120
150
  (0, render_1.formatTime)(it.changedAt, tty),
121
- it.operator || '—',
151
+ (0, _operator_1.parseOperator)(it.operator).name || '—',
122
152
  it.targetTable || '—',
123
153
  it.changeType,
124
154
  it.summary || '—',
125
155
  ]);
126
156
  (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
127
- if (!opts.all && lastHasMore && lastCursor) {
157
+ if (!allMode && lastHasMore && lastCursor) {
128
158
  process.stderr.write(`(more results; use --cursor ${lastCursor} or --all)\n`);
129
159
  }
130
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
- // 不可逆操作,TTY 默认要求 y/N;--yes 跳过
53
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
54
- // 文案对齐 PRD:先讲不可逆,再问是否初始化;syncData 时追加一句而不是塞进同一句话
55
- const suffix = opts.syncData ? ' (existing data will be copied to dev)' : '';
56
- const ok = await confirm(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `);
57
- if (!ok) {
58
- (0, output_1.emit)('Aborted.');
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)('Computing migration diff');
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 下先 diff 给用户审;--yes 直接打到 online
110
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
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)('Computing migration diff');
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
- renderDiff(preview);
127
- const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
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 to 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,9 @@ 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
- const node_readline_1 = __importDefault(require("node:readline"));
42
38
  const api = __importStar(require("../../../api/index"));
43
39
  const shared_1 = require("../../../cli/commands/shared");
44
40
  const colors_1 = require("../../../utils/colors");
@@ -47,6 +43,7 @@ const output_1 = require("../../../utils/output");
47
43
  const poll_1 = require("../../../utils/poll");
48
44
  const render_1 = require("../../../utils/render");
49
45
  const time_1 = require("../../../utils/time");
46
+ const _destructive_1 = require("../../../cli/handlers/db/_destructive");
50
47
  // ── recovery diff ──
51
48
  //
52
49
  // PITR preview 是异步任务:dataloom 立即返 previewRequestId,CLI 自己 poll 直到 success/failed。
@@ -61,18 +58,36 @@ async function handleDbRecoveryDiff(target, opts) {
61
58
  async function handleDbRecoveryApply(target, opts) {
62
59
  const appId = (0, shared_1.resolveAppId)(opts);
63
60
  const ts = normalizeTimestamp(target);
64
- // PRD 要求 apply 输出包含 N tables affected / Ms elapsed,dataloom apply 路径不
65
- // 回带这俩信息(pgsvc.BranchRestore 是异步触发、不等终态)。所以 CLI 这里始终
66
- // 先跑一次 preview——它本来就是 PITR 推荐流程,--yes 时静默跑、TTY 时同时给用户审。
61
+ // PRD 要求 apply 输出包含 N tables affected / Ms elapsed
62
+ // tables_affected preview 拿;elapsed 用 CLI 端墙钟(dataloom apply 是异步
63
+ // 触发,server-side elapsed=0;下面 poll 真终态后用本地计时器算 elapsed)。
67
64
  const preview = await runRecoveryPreview(appId, ts);
68
65
  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
- if (!opts.yes && !(0, output_1.isJsonMode)()) {
74
- renderDiff(ts, preview);
75
- const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
66
+ // 0 changes 短路:目标时间点与当前一致,apply 没意义。pretty 模式渲染 preview
67
+ // "No changes database is already at this state." 后直接退;--json 模式
68
+ // status="no_changes" envelope 让下游识别。不进 confirm 也不下发 apply。
69
+ if ((preview.changes?.length ?? 0) === 0) {
70
+ if ((0, output_1.isJsonMode)()) {
71
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
72
+ status: 'no_changes',
73
+ target: ts,
74
+ tablesAffected: 0,
75
+ elapsedSeconds: 0,
76
+ }));
77
+ }
78
+ else {
79
+ renderDiff(ts, preview);
80
+ }
81
+ return;
82
+ }
83
+ // --yes 跳过;非 TTY 抛 DESTRUCTIVE_REQUIRES_CONFIRM;TTY 渲染 diff(pretty)+ 交互确认。
84
+ // 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
85
+ if (opts.yes !== true) {
86
+ (0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
87
+ // --json 模式跳过 pretty diff 渲染避免污染 stdout envelope;TTY pretty 模式渲染
88
+ if (!(0, output_1.isJsonMode)())
89
+ renderDiff(ts, preview);
90
+ const ok = await (0, _destructive_1.askYesNo)(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
76
91
  if (!ok) {
77
92
  (0, output_1.emit)('Aborted.');
78
93
  return;
@@ -85,7 +100,12 @@ async function handleDbRecoveryApply(target, opts) {
85
100
  catch (err) {
86
101
  throw decorateRecoveryError(err);
87
102
  }
88
- const elapsed = result.elapsedSeconds && result.elapsedSeconds > 0 ? result.elapsedSeconds : elapsedFromPreview;
103
+ // 关键:墙钟从 apply 触发瞬间开始,poll 真完成时停。dataloom restore 是异步触发,
104
+ // 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
105
+ // Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
106
+ const startedAt = Date.now();
107
+ await waitRecoveryDone(appId, ts);
108
+ const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
89
109
  if ((0, output_1.isJsonMode)()) {
90
110
  // PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
91
111
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
@@ -101,6 +121,38 @@ async function handleDbRecoveryApply(target, opts) {
101
121
  `(${String(tablesAffected)} tables affected, ${String(elapsed)}s elapsed)`;
102
122
  (0, output_1.emit)(tty ? colors_1.c.success(`✓ ${body}`) : `OK ${body}`);
103
123
  }
124
+ /**
125
+ * 触发 BranchRestore 后 poll 直到任务终态。dataloom 端 Redis 缓存 workspace 级
126
+ * status,归一成 running / success / failed:
127
+ * - running → 继续 poll
128
+ * - success → 退出,CLI 渲染 ✓
129
+ * - failed → 抛 DB_API_k_dl_1300036 + errorMessage
130
+ *
131
+ * 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
132
+ * 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
133
+ */
134
+ async function waitRecoveryDone(appId, target) {
135
+ try {
136
+ return await (0, poll_1.pollUntilDone)({
137
+ label: 'recovery apply',
138
+ spinnerLabel: 'Restoring database to target time',
139
+ intervalMs: 2000,
140
+ fetch: () => api.db.getRecoveryStatus({ appId }),
141
+ isDone: (cur) => {
142
+ const status = (cur.status || '').toLowerCase();
143
+ if (status === 'success')
144
+ return { done: true, value: cur };
145
+ if (status === 'failed') {
146
+ throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? `recovery to ${target} failed`);
147
+ }
148
+ return { done: false };
149
+ },
150
+ });
151
+ }
152
+ catch (err) {
153
+ throw decorateRecoveryError(err);
154
+ }
155
+ }
104
156
  /**
105
157
  * 触发 PITR 预览任务并轮询到终态:
106
158
  * 1. POST /db/recovery dryRun=true → previewRequestId
@@ -125,6 +177,7 @@ async function runRecoveryPreview(appId, ts) {
125
177
  try {
126
178
  return await (0, poll_1.pollUntilDone)({
127
179
  label: 'recovery preview',
180
+ spinnerLabel: 'Computing recovery preview',
128
181
  intervalMs: 1000,
129
182
  fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
130
183
  isDone: (cur) => {
@@ -171,20 +224,20 @@ function normalizeTimestamp(input) {
171
224
  }
172
225
  // PRD diff 输出,三套:
173
226
  //
174
- // TTY pretty(缩进 prose、带 Unicode 箭头 / 波浪号、表名按列对齐):
227
+ // TTY pretty(缩进 prose、带 Unicode 箭头、表名按列对齐):
175
228
  // Recovery preview (→ 2026-04-15T10:00:00Z):
176
229
  //
177
230
  // tables affected: 2
178
- // users: +3 rows, -1 row, ~5 rows modified
179
- // orders: table will be restored (was dropped at 10:25:00)
231
+ // users: +3 rows, -1 row
232
+ // orders: table will be restored
180
233
  //
181
234
  // estimated time: ~30s
182
235
  //
183
236
  // non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
184
237
  // Recovery preview (-> 2026-04-15T10:00:00Z):
185
238
  // tables_affected\t2
186
- // users\t+3 rows, -1 row, ~5 rows modified
187
- // orders\ttable will be restored (was dropped at 10:25:00)
239
+ // users\t+3 rows, -1 row
240
+ // orders\ttable will be restored
188
241
  // estimated_time\t30
189
242
  //
190
243
  // --json:标准 envelope,字段名固定 snake_case。
@@ -200,7 +253,6 @@ function renderDiff(target, preview) {
200
253
  table: c.table,
201
254
  inserted: c.inserted,
202
255
  deleted: c.deleted,
203
- modified: c.modified,
204
256
  action: c.action,
205
257
  droppedAt: c.droppedAt,
206
258
  })),
@@ -250,51 +302,31 @@ function renderDiffPipe(target, changes, tablesAffected, estimated) {
250
302
  return lines.join('\n');
251
303
  }
252
304
  function describeChange(c) {
253
- // 表级操作:dataloom 现在把 schema diff 透出为 action="schema_<diffType>",
254
- // 老协议的 restore_table / drop / create 一并保留兼容。
305
+ // dataloom action PRD 三态 + unavailable 边界:
306
+ // restore_table schema diff 显示该表在目标时间点存在但当前没有
307
+ // drop_table — 该表当前有但目标时间点没有
308
+ // alter_table — 两侧都在但结构有差异(列 / 索引 / 关系等)
309
+ // unavailable — PITR diff 算不出来,droppedAt 字段复用透传 message
310
+ // 没 action 时是数据行数变化,走下面的 +N / -N 渲染。
255
311
  if (c.action === 'restore_table') {
256
- const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : '';
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}`;
312
+ return 'table will be restored';
262
313
  }
263
- if (c.action === 'create') {
264
- const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : '';
265
- return `table will be created${ts}`;
314
+ if (c.action === 'drop_table') {
315
+ return 'table will be dropped';
266
316
  }
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
- }
317
+ if (c.action === 'alter_table') {
318
+ return 'table will be altered';
284
319
  }
285
320
  if (c.action === 'unavailable') {
286
- // dataloom 端 count 失败的表,复用 droppedAt 透传 message
287
321
  return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== '' ? `: ${c.droppedAt}` : ''}`;
288
322
  }
289
- // 数据变更:+N rows / -N rows / ~N rows modified
323
+ // 数据变更:+N rows / -N rows
290
324
  const parts = [];
291
325
  if (c.inserted !== undefined && c.inserted !== 0)
292
326
  parts.push(`+${String(c.inserted)} rows`);
293
327
  if (c.deleted !== undefined && c.deleted !== 0) {
294
328
  parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? 'row' : 'rows'}`);
295
329
  }
296
- if (c.modified !== undefined && c.modified !== 0)
297
- parts.push(`~${String(c.modified)} rows modified`);
298
330
  return parts.length === 0 ? 'no changes' : parts.join(', ');
299
331
  }
300
332
  /**
@@ -346,12 +378,3 @@ function decorateRecoveryError(err) {
346
378
  return err;
347
379
  }
348
380
  }
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
- * PRD 单文件场景下提示带具体路径:`? Delete '/path'? (y/N)`,
109
- * 多文件场景汇总条数:`? Delete N files? (y/N)`。
110
- * `firstInput` 是用户传入的第一个值(可能是 path 或 file_name),
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 ? `? Delete '${firstInput}'? (y/N) ` : `? Delete ${String(count)} files? (y/N) `;
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');
@@ -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
- // 立即拉一次(绝大多数轻量任务在 dataloom 端已是同步语义,第一次 fetch 就能拿到 success)
10
- for (;;) {
11
- const cur = await opts.fetch();
12
- const verdict = opts.isDone(cur);
13
- if (verdict.done)
14
- return verdict.value;
15
- if (Date.now() + interval > deadline) {
16
- throw new error_1.AppError('TASK_TIMEOUT', `${opts.label} did not complete within ${String(Math.round(timeout / 1000))}s`, {
17
- next_actions: [
18
- 'The task may still be running server-side. Retry the command, or check `miaoda db migration diff` to verify final state.',
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
- await sleep(interval);
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';
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.3-alpha.a25d692",
3
+ "version": "0.1.3-alpha.a7a2f65",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {