@lark-apaas/miaoda-cli 0.1.2-alpha.96b37fa → 0.1.2-alpha.9e74f1d

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.
@@ -10,7 +10,9 @@ exports.setAuditConfig = setAuditConfig;
10
10
  exports.listAuditLog = listAuditLog;
11
11
  exports.migrationInit = migrationInit;
12
12
  exports.migrate = migrate;
13
+ exports.getMigrationStatus = getMigrationStatus;
13
14
  exports.recover = recover;
15
+ exports.getRecoveryPreview = getRecoveryPreview;
14
16
  exports.getDbQuota = getDbQuota;
15
17
  const http_1 = require("../../utils/http");
16
18
  const error_1 = require("../../utils/error");
@@ -33,6 +35,22 @@ async function mapDbHttpError(err, url, ctx,
33
35
  onErrorBody) {
34
36
  if (err instanceof error_1.AppError)
35
37
  throw err;
38
+ // 客户端超时 / abort:SdkHttpError 时 response 通常缺失(status=0),落到 throw 时
39
+ // message 形如 'Failed to recover: 0' 让用户看不懂。识别 abort / timeout 关键字
40
+ // 转成专用错误码 + 友好 hint。
41
+ if (err instanceof Error) {
42
+ const msg = err.message.toLowerCase();
43
+ if (msg.includes("aborted") ||
44
+ msg.includes("timeout") ||
45
+ err.name === "AbortError" ||
46
+ err.name === "TimeoutError") {
47
+ throw new error_1.AppError("REQUEST_TIMEOUT", `${ctx}: request timed out`, {
48
+ next_actions: [
49
+ "Server-side async tasks may take up to 60s. Retry the command if the underlying task likely succeeded.",
50
+ ],
51
+ });
52
+ }
53
+ }
36
54
  if (err instanceof http_client_1.HttpError) {
37
55
  const status = err.response?.status ?? 0;
38
56
  const statusText = err.response?.statusText ?? "";
@@ -484,6 +502,31 @@ async function migrate(opts) {
484
502
  const respBody = (await response.json());
485
503
  return (0, client_1.extractData)(respBody);
486
504
  }
505
+ /**
506
+ * 后端:GET /v1/dataloom/app/{appId}/db/migration/status?taskId=...
507
+ * CLI 拿到 migration apply 的 taskId 后定时调本接口,直到 status=success/failed。
508
+ * 网络层超时仍走 mapDbHttpError → 单次 30s;轮询节奏由 CLI handler 自行控制。
509
+ */
510
+ async function getMigrationStatus(opts) {
511
+ const client = (0, http_1.getHttpClient)();
512
+ const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/migration/status", {
513
+ taskId: opts.taskId,
514
+ dbBranch: opts.dbBranch,
515
+ });
516
+ const start = Date.now();
517
+ let response;
518
+ try {
519
+ response = await client.get(url);
520
+ (0, client_1.traceHttp)("GET", url, start, response);
521
+ }
522
+ catch (err) {
523
+ (0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
524
+ await mapDbHttpError(err, url, "Failed to get migration status");
525
+ throw err; // 不可达
526
+ }
527
+ const body = (await response.json());
528
+ return (0, client_1.extractData)(body);
529
+ }
487
530
  // ── db recovery → InnerAdminRecover ──
488
531
  /**
489
532
  * 后端:POST /v1/dataloom/app/{appId}/db/recovery
@@ -509,6 +552,30 @@ async function recover(opts) {
509
552
  const respBody = (await response.json());
510
553
  return (0, client_1.extractData)(respBody);
511
554
  }
555
+ /**
556
+ * 后端:GET /v1/dataloom/app/{appId}/db/recovery/preview?previewRequestId=...
557
+ * CLI 拿到 recovery diff 的 previewRequestId 后定时调本接口直到 previewStatus=success/failed。
558
+ */
559
+ async function getRecoveryPreview(opts) {
560
+ const client = (0, http_1.getHttpClient)();
561
+ const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/recovery/preview", {
562
+ previewRequestId: opts.previewRequestId,
563
+ dbBranch: opts.dbBranch,
564
+ });
565
+ const start = Date.now();
566
+ let response;
567
+ try {
568
+ response = await client.get(url);
569
+ (0, client_1.traceHttp)("GET", url, start, response);
570
+ }
571
+ catch (err) {
572
+ (0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
573
+ await mapDbHttpError(err, url, "Failed to get recovery preview");
574
+ throw err; // 不可达
575
+ }
576
+ const body = (await response.json());
577
+ return (0, client_1.extractData)(body);
578
+ }
512
579
  // ── db quota → InnerAdminGetDbQuota ──
513
580
  /**
514
581
  * 后端:GET /v1/dataloom/app/{appId}/db/quota?dbBranch=
@@ -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.recover = 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.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; } });
@@ -12,7 +12,9 @@ Object.defineProperty(exports, "setAuditConfig", { enumerable: true, get: functi
12
12
  Object.defineProperty(exports, "listAuditLog", { enumerable: true, get: function () { return api_1.listAuditLog; } });
13
13
  Object.defineProperty(exports, "migrationInit", { enumerable: true, get: function () { return api_1.migrationInit; } });
14
14
  Object.defineProperty(exports, "migrate", { enumerable: true, get: function () { return api_1.migrate; } });
15
+ Object.defineProperty(exports, "getMigrationStatus", { enumerable: true, get: function () { return api_1.getMigrationStatus; } });
15
16
  Object.defineProperty(exports, "recover", { enumerable: true, get: function () { return api_1.recover; } });
17
+ Object.defineProperty(exports, "getRecoveryPreview", { enumerable: true, get: function () { return api_1.getRecoveryPreview; } });
16
18
  Object.defineProperty(exports, "getDbQuota", { enumerable: true, get: function () { return api_1.getDbQuota; } });
17
19
  var client_1 = require("./client");
18
20
  Object.defineProperty(exports, "buildInnerUrl", { enumerable: true, get: function () { return client_1.buildInnerUrl; } });
@@ -112,13 +112,27 @@ async function handleDbAuditEnable(table, opts) {
112
112
  });
113
113
  }
114
114
  const appId = (0, shared_1.resolveAppId)(opts);
115
- const status = await api.db.setAuditConfig({
116
- appId,
117
- table,
118
- enabled: true,
119
- retention,
120
- dbBranch: opts.env,
121
- });
115
+ let status;
116
+ try {
117
+ status = await api.db.setAuditConfig({
118
+ appId,
119
+ table,
120
+ enabled: true,
121
+ retention,
122
+ dbBranch: opts.env,
123
+ });
124
+ }
125
+ catch (err) {
126
+ // PRD: 重复 enable 报错时附带 hint,引导用户去 status 看 retention 或换值更新
127
+ if (err instanceof error_1.AppError && err.code === "DB_API_k_dl_1300030") {
128
+ throw new error_1.AppError(err.code, err.message, {
129
+ next_actions: [
130
+ `Use \`miaoda db audit status ${table}\` to check current retention, or run this command with a different --retention to update.`,
131
+ ],
132
+ });
133
+ }
134
+ throw err;
135
+ }
122
136
  // PRD JSON:{"data": {"table": "...", "enabled": true, "retention": "..."}}
123
137
  if ((0, output_1.isJsonMode)()) {
124
138
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
@@ -141,12 +155,24 @@ async function handleDbAuditDisable(table, opts) {
141
155
  });
142
156
  }
143
157
  const appId = (0, shared_1.resolveAppId)(opts);
144
- const status = await api.db.setAuditConfig({
145
- appId,
146
- table,
147
- enabled: false,
148
- dbBranch: opts.env,
149
- });
158
+ let status;
159
+ try {
160
+ status = await api.db.setAuditConfig({
161
+ appId,
162
+ table,
163
+ enabled: false,
164
+ dbBranch: opts.env,
165
+ });
166
+ }
167
+ catch (err) {
168
+ // PRD: 重复 disable / 未启用就 disable 报错时附带 hint,引导用户去看哪些表已开启
169
+ if (err instanceof error_1.AppError && err.code === "DB_API_k_dl_1300031") {
170
+ throw new error_1.AppError(err.code, err.message, {
171
+ next_actions: ["Use `miaoda db audit status` to see which tables have audit enabled."],
172
+ });
173
+ }
174
+ throw err;
175
+ }
150
176
  if ((0, output_1.isJsonMode)()) {
151
177
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
152
178
  table: status.table,
@@ -42,45 +42,86 @@ exports.handleDbMigrationApply = handleDbMigrationApply;
42
42
  const node_readline_1 = __importDefault(require("node:readline"));
43
43
  const api = __importStar(require("../../../api/index"));
44
44
  const shared_1 = require("../../../cli/commands/shared");
45
+ const error_1 = require("../../../utils/error");
45
46
  const output_1 = require("../../../utils/output");
47
+ const poll_1 = require("../../../utils/poll");
46
48
  const render_1 = require("../../../utils/render");
47
49
  async function handleDbMigrationInit(opts) {
48
50
  const appId = (0, shared_1.resolveAppId)(opts);
49
51
  // 不可逆操作,TTY 默认要求 y/N;--yes 跳过
50
52
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
51
- const ok = await confirm(`? Initialize multi-env (single → dev/online), this is IRREVERSIBLE${opts.syncData ? " and will copy existing data to dev" : ""}? (y/N) `);
53
+ // 文案对齐 PRD:先讲不可逆,再问是否初始化;syncData 时追加一句而不是塞进同一句话
54
+ const suffix = opts.syncData ? " (existing data will be copied to dev)" : "";
55
+ const ok = await confirm(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `);
52
56
  if (!ok) {
53
57
  (0, output_1.emit)("Aborted.");
54
58
  return;
55
59
  }
56
60
  }
57
- const result = await api.db.migrationInit({ appId, syncData: opts.syncData });
61
+ let result;
62
+ try {
63
+ result = await api.db.migrationInit({ appId, syncData: opts.syncData });
64
+ }
65
+ catch (err) {
66
+ // PRD: 重复 init 报错带 hint,引导用户去 diff 看待发布变更
67
+ if (err instanceof error_1.AppError && err.code === "DB_API_k_dl_1300034") {
68
+ throw new error_1.AppError(err.code, err.message, {
69
+ next_actions: ["Run `miaoda db migration diff` to view pending changes."],
70
+ });
71
+ }
72
+ throw err;
73
+ }
58
74
  if ((0, output_1.isJsonMode)()) {
59
75
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(result));
60
76
  return;
61
77
  }
78
+ // PRD 单行格式(不用 key:value 表格):
79
+ // 默认 ✓ / OK: "Multi-env initialized (dev / online)"
80
+ // --sync-data: "Multi-env initialized, data synced to dev"
62
81
  const tty = (0, render_1.isStdoutTty)();
63
- (0, output_1.emit)((0, render_1.renderKeyValue)([
64
- ["Status", result.status],
65
- ["Environments", result.environments.join(", ")],
66
- ["Data synced", String(result.dataSynced)],
67
- ], tty));
82
+ const prefix = tty ? "✓" : "OK";
83
+ const body = result.dataSynced
84
+ ? "Multi-env initialized, data synced to dev"
85
+ : `Multi-env initialized (${result.environments.join(" / ")})`;
86
+ (0, output_1.emit)(`${prefix} ${body}`);
68
87
  }
69
88
  async function handleDbMigrationDiff(opts) {
70
89
  const appId = (0, shared_1.resolveAppId)(opts);
71
- const result = await api.db.migrate({ appId, dryRun: true });
90
+ let result;
91
+ try {
92
+ result = await api.db.migrate({ appId, dryRun: true });
93
+ }
94
+ catch (err) {
95
+ throw decorateMigrationError(err);
96
+ }
97
+ // PRD: diff 在无待发布变更时报错带 hint,而不是渲染空列表
98
+ if (result.changes.length === 0) {
99
+ throw new error_1.AppError("DB_API_k_dl_1300035", `No pending changes between ${result.from} and ${result.to}`, {
100
+ next_actions: [
101
+ 'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
102
+ ],
103
+ });
104
+ }
72
105
  renderDiff(result);
73
106
  }
74
107
  async function handleDbMigrationApply(opts) {
75
108
  const appId = (0, shared_1.resolveAppId)(opts);
76
109
  // TTY 下先 diff 给用户审;--yes 直接打到 online
77
110
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
78
- const preview = await api.db.migrate({ appId, dryRun: true });
111
+ let preview;
112
+ try {
113
+ preview = await api.db.migrate({ appId, dryRun: true });
114
+ }
115
+ catch (err) {
116
+ throw decorateMigrationError(err);
117
+ }
79
118
  if (preview.changes.length === 0) {
80
- (0, output_1.emit)(`Error: No pending changes between ${preview.from} and ${preview.to}`);
81
- (0, output_1.emit)(` hint: Make schema changes in dev first.`);
82
- process.exitCode = 1;
83
- return;
119
+ // PRD 文案 + hint
120
+ throw new error_1.AppError("DB_API_k_dl_1300035", `No pending changes between ${preview.from} and ${preview.to}`, {
121
+ next_actions: [
122
+ 'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
123
+ ],
124
+ });
84
125
  }
85
126
  renderDiff(preview);
86
127
  const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
@@ -89,22 +130,49 @@ async function handleDbMigrationApply(opts) {
89
130
  return;
90
131
  }
91
132
  }
92
- const result = await api.db.migrate({ appId, dryRun: false });
133
+ let result;
134
+ try {
135
+ result = await api.db.migrate({ appId, dryRun: false });
136
+ }
137
+ catch (err) {
138
+ throw decorateMigrationError(err);
139
+ }
140
+ // dataloom 立即返 taskId(apply 实际是异步流水线)。CLI 自己 poll 直到 success/failed,
141
+ // 避免单次 HTTP 长连接 30s+ 被网关 / SDK 中断。
142
+ if (result.taskId === undefined || result.taskId === "") {
143
+ throw new error_1.AppError("INTERNAL_DB_ERROR", "migration apply did not return taskId");
144
+ }
145
+ const taskId = result.taskId;
146
+ const final = await (0, poll_1.pollUntilDone)({
147
+ label: "migration apply",
148
+ intervalMs: 1000,
149
+ fetch: () => api.db.getMigrationStatus({ appId, taskId }),
150
+ isDone: (cur) => {
151
+ // 同 recovery preview,dataloom 上下游枚举大小写不完全统一,客户端归一兜底。
152
+ const status = cur.status.toLowerCase();
153
+ if (status === "success")
154
+ return { done: true, value: cur };
155
+ if (status === "failed") {
156
+ throw new error_1.AppError("DB_API_k_dl_1300030", cur.errorMessage ?? `migration apply failed (taskId=${taskId})`);
157
+ }
158
+ return { done: false };
159
+ },
160
+ });
161
+ const appliedCount = final.changesApplied ?? result.changes.length;
93
162
  if ((0, output_1.isJsonMode)()) {
94
163
  // PRD:{"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}
95
164
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
96
- status: result.status ?? "applied",
165
+ status: "applied",
97
166
  from: result.from,
98
167
  to: result.to,
99
- changesApplied: result.changesApplied ?? result.changes.length,
168
+ changesApplied: appliedCount,
100
169
  }));
101
170
  return;
102
171
  }
103
172
  const tty = (0, render_1.isStdoutTty)();
104
173
  const prefix = tty ? "✓" : "OK";
105
174
  const arrow = tty ? "→" : "->";
106
- const applied = result.changesApplied ?? result.changes.length;
107
- (0, output_1.emit)(`${prefix} Applied ${result.from} ${arrow} ${result.to} (${String(applied)} changes)`);
175
+ (0, output_1.emit)(`${prefix} Applied ${result.from} ${arrow} ${result.to} (${String(appliedCount)} changes)`);
108
176
  }
109
177
  // ── helpers ──
110
178
  // PRD diff 输出:
@@ -134,6 +202,28 @@ function renderDiff(result) {
134
202
  (0, output_1.emit)(`${result.from} ${arrow} ${result.to} (${String(result.changes.length)} changes):\n\n` +
135
203
  result.changes.map((c) => ` ${c.statement}`).join("\n"));
136
204
  }
205
+ // decorateMigrationError 给 migration / recovery 路径上的几个错误码补 PRD 规定的 hint。
206
+ // dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
207
+ function decorateMigrationError(err) {
208
+ if (!(err instanceof error_1.AppError))
209
+ return err;
210
+ switch (err.code) {
211
+ case "DB_API_k_dl_1300039":
212
+ // 多环境未初始化:引导先 init
213
+ return new error_1.AppError(err.code, err.message, {
214
+ next_actions: ["Run `miaoda db migration init` to set up multi-env first."],
215
+ });
216
+ case "DB_API_k_dl_1300035":
217
+ // 无待发布变更:引导先在 dev 改 schema
218
+ return new error_1.AppError(err.code, err.message, {
219
+ next_actions: [
220
+ 'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
221
+ ],
222
+ });
223
+ default:
224
+ return err;
225
+ }
226
+ }
137
227
  async function confirm(prompt) {
138
228
  const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
139
229
  return new Promise((resolve) => {
@@ -43,60 +43,121 @@ const api = __importStar(require("../../../api/index"));
43
43
  const shared_1 = require("../../../cli/commands/shared");
44
44
  const error_1 = require("../../../utils/error");
45
45
  const output_1 = require("../../../utils/output");
46
+ const poll_1 = require("../../../utils/poll");
46
47
  const render_1 = require("../../../utils/render");
47
48
  // ── recovery diff ──
49
+ //
50
+ // PITR preview 是异步任务:dataloom 立即返 previewRequestId,CLI 自己 poll 直到 success/failed。
51
+ // 失败时 dataloom 已用 translateRestoreErr 把中文窗口错误翻成 PRD 英文 + k_dl_1300036 code,
52
+ // CLI 走 decorateRecoveryError 补 hint。
48
53
  async function handleDbRecoveryDiff(target, opts) {
49
54
  const appId = (0, shared_1.resolveAppId)(opts);
50
55
  const ts = normalizeTimestamp(target);
51
- const result = await api.db.recover({ appId, target: ts, dryRun: true });
52
- renderDiff(result);
56
+ const preview = await runRecoveryPreview(appId, ts);
57
+ renderDiff(ts, preview);
53
58
  }
54
59
  async function handleDbRecoveryApply(target, opts) {
55
60
  const appId = (0, shared_1.resolveAppId)(opts);
56
61
  const ts = normalizeTimestamp(target);
57
62
  // PITR 高危:TTY 下先 diff 给用户审;--yes 直接执行
58
63
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
59
- const preview = await api.db.recover({ appId, target: ts, dryRun: true });
60
- renderDiff(preview);
61
- const ok = await confirm(`? Restore database to ${preview.target}? This will overwrite current data. (y/N) `);
64
+ const preview = await runRecoveryPreview(appId, ts);
65
+ renderDiff(ts, preview);
66
+ const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
62
67
  if (!ok) {
63
68
  (0, output_1.emit)("Aborted.");
64
69
  return;
65
70
  }
66
71
  }
67
- const result = await api.db.recover({ appId, target: ts, dryRun: false });
72
+ let result;
73
+ try {
74
+ result = await api.db.recover({ appId, target: ts, dryRun: false });
75
+ }
76
+ catch (err) {
77
+ throw decorateRecoveryError(err);
78
+ }
68
79
  if ((0, output_1.isJsonMode)()) {
69
- // PRD:{"status": "restored", "target": "...", "tables_affected": 2, "elapsed_seconds": 30}
80
+ // PRD:{"status": "restored", "target": "...", "elapsed_seconds": 30}
81
+ // tables_affected 来源于 preview,apply 路径不再回带,由 CLI 不强求字段。
70
82
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
71
83
  status: result.status ?? "restored",
72
84
  target: result.target,
73
- tablesAffected: result.tablesAffected,
74
85
  elapsedSeconds: result.elapsedSeconds ?? 0,
75
86
  }));
76
87
  return;
77
88
  }
78
89
  const tty = (0, render_1.isStdoutTty)();
79
90
  const prefix = tty ? "✓" : "OK";
80
- (0, output_1.emit)(`${prefix} Database restored to ${result.target} ` +
81
- `(${String(result.tablesAffected)} tables affected, ${String(result.elapsedSeconds ?? 0)}s elapsed)`);
91
+ (0, output_1.emit)(`${prefix} Database restore triggered for ${result.target} ` +
92
+ `(${String(result.elapsedSeconds ?? 0)}s elapsed)`);
93
+ }
94
+ /**
95
+ * 触发 PITR 预览任务并轮询到终态:
96
+ * 1. POST /db/recovery dryRun=true → previewRequestId
97
+ * 2. GET /db/recovery/preview?previewRequestId 直到 previewStatus=success/failed
98
+ *
99
+ * 错误透传:业务错误(窗口超限 / 格式错)由 dataloom 直接 throw,CLI 在 catch 里
100
+ * 补 hint。failed 状态下 dataloom 已把 errorMessage 翻好,CLI 包成 k_dl_1300036
101
+ * 让 decorateRecoveryError 命中窗口超限 hint。
102
+ */
103
+ async function runRecoveryPreview(appId, ts) {
104
+ let triggered;
105
+ try {
106
+ triggered = await api.db.recover({ appId, target: ts, dryRun: true });
107
+ }
108
+ catch (err) {
109
+ throw decorateRecoveryError(err);
110
+ }
111
+ if (triggered.previewRequestId === undefined || triggered.previewRequestId === "") {
112
+ throw new error_1.AppError("INTERNAL_DB_ERROR", "recovery diff did not return previewRequestId");
113
+ }
114
+ const previewRequestId = triggered.previewRequestId;
115
+ try {
116
+ return await (0, poll_1.pollUntilDone)({
117
+ label: "recovery preview",
118
+ intervalMs: 1000,
119
+ fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
120
+ isDone: (cur) => {
121
+ // dataloom 内部 pgsvc 返回首字母大写枚举(Pending/Running/Success/Failed),
122
+ // 但 admin-inner thrift 契约里写的是小写。客户端做大小写归一兜底,避免依赖
123
+ // 上游 case 一致性导致死循环。
124
+ const status = cur.previewStatus.toLowerCase();
125
+ if (status === "success")
126
+ return { done: true, value: cur };
127
+ if (status === "failed") {
128
+ // 复用 k_dl_1300036(窗口超限 / 预览失败的统一对外码),让 decorateRecoveryError
129
+ // 命中后给 PITR 7 天窗口的 hint。errorMessage 优先用 dataloom 翻好的 PRD 文案。
130
+ throw new error_1.AppError("DB_API_k_dl_1300036", cur.errorMessage ?? "recovery preview failed");
131
+ }
132
+ return { done: false };
133
+ },
134
+ });
135
+ }
136
+ catch (err) {
137
+ throw decorateRecoveryError(err);
138
+ }
82
139
  }
83
140
  // ── helpers ──
84
141
  /**
85
- * 把用户传入的时间统一成 ISO 8601 UTC。
86
- * 接受:`YYYY-MM-DD` / `YYYY-MM-DD HH:MM:SS` / 完整 ISO 8601。
142
+ * 把用户传入的时间统一成 ISO 8601 UTC。PRD 只接受两种格式:
143
+ * - `YYYY-MM-DD`(按 UTC 0 点解释)
144
+ * - 完整 ISO 8601 `YYYY-MM-DDTHH:MM:SS[Z|±HH:MM]`
145
+ *
146
+ * 严格 regex 匹配 + 不用 new Date(input) 兜底——Node Date 解析 `2026/04/15` 这种
147
+ * 非 ISO 字符串居然成功(落到本地时区),绕过格式校验把脏值送到后端,导致用户拿到
148
+ * 「窗口超限」错误而不是「格式错误」。PRD 明确这两种格式以外的都应该报 INVALID_TIMESTAMP。
87
149
  */
88
150
  function normalizeTimestamp(input) {
89
- const FORMAT_HINT = "Use ISO 8601 / yyyy-mm-dd / yyyy-mm-dd HH:MM:SS.";
151
+ // PRD hint 文案对齐 PRD 截图(同时是 dataloom ErrInvalidTimestamp hint)
152
+ const FORMAT_HINT = "Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z";
90
153
  if (input === "") {
91
154
  throw new error_1.AppError("ARGS_INVALID", "target timestamp is required", {
92
155
  next_actions: ["Usage: miaoda db recovery diff|apply <timestamp>"],
93
156
  });
94
157
  }
158
+ // 仅日期:YYYY-MM-DD → 按 UTC 0 点
95
159
  if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
96
- return new Date(`${input}T00:00:00Z`).toISOString();
97
- }
98
- if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(input)) {
99
- const d = new Date(input.replace(" ", "T"));
160
+ const d = new Date(`${input}T00:00:00Z`);
100
161
  if (Number.isNaN(d.getTime())) {
101
162
  throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
102
163
  next_actions: [FORMAT_HINT],
@@ -104,13 +165,20 @@ function normalizeTimestamp(input) {
104
165
  }
105
166
  return d.toISOString();
106
167
  }
107
- const d = new Date(input);
108
- if (Number.isNaN(d.getTime())) {
109
- throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
110
- next_actions: [FORMAT_HINT],
111
- });
168
+ // 完整 ISO 8601 datetime:T 分隔,秒可选 ms,Z 或 ±HH:MM 时区
169
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
170
+ const d = new Date(input);
171
+ if (Number.isNaN(d.getTime())) {
172
+ throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
173
+ next_actions: [FORMAT_HINT],
174
+ });
175
+ }
176
+ return d.toISOString();
112
177
  }
113
- return d.toISOString();
178
+ // 其它一律按格式错误拒绝(`2026/04/15` / `2026.04.15` / `2026年4月15日` 等都走这里)
179
+ throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
180
+ next_actions: [FORMAT_HINT],
181
+ });
114
182
  }
115
183
  // PRD diff 输出(结构化 prose):
116
184
  // Recovery preview (→ 2026-04-15T10:00:00Z):
@@ -120,12 +188,15 @@ function normalizeTimestamp(input) {
120
188
  // orders: table will be restored (was dropped at 10:25:00)
121
189
  //
122
190
  // estimated time: ~30s
123
- function renderDiff(result) {
191
+ function renderDiff(target, preview) {
192
+ const changes = preview.changes ?? [];
193
+ const tablesAffected = preview.tablesAffected ?? changes.length;
194
+ const estimated = preview.estimatedSeconds;
124
195
  if ((0, output_1.isJsonMode)()) {
125
196
  (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
126
- target: result.target,
127
- tablesAffected: result.tablesAffected,
128
- changes: result.changes.map((c) => ({
197
+ target,
198
+ tablesAffected,
199
+ changes: changes.map((c) => ({
129
200
  table: c.table,
130
201
  inserted: c.inserted,
131
202
  deleted: c.deleted,
@@ -133,40 +204,56 @@ function renderDiff(result) {
133
204
  action: c.action,
134
205
  droppedAt: c.droppedAt,
135
206
  })),
136
- estimatedSeconds: result.estimatedSeconds ?? 0,
207
+ estimatedSeconds: estimated ?? 0,
137
208
  }));
138
209
  return;
139
210
  }
140
211
  const tty = (0, render_1.isStdoutTty)();
141
212
  const arrow = tty ? "→" : "->";
142
- if (result.changes.length === 0) {
143
- (0, output_1.emit)(`Recovery preview (${arrow} ${result.target}):\n\n` +
213
+ if (changes.length === 0) {
214
+ (0, output_1.emit)(`Recovery preview (${arrow} ${target}):\n\n` +
144
215
  ` No changes — database is already at this state.`);
145
216
  return;
146
217
  }
147
218
  const lines = [
148
- `Recovery preview (${arrow} ${result.target}):`,
219
+ `Recovery preview (${arrow} ${target}):`,
149
220
  "",
150
- ` tables affected: ${String(result.tablesAffected)}`,
221
+ ` tables affected: ${String(tablesAffected)}`,
151
222
  ];
152
- for (const c of result.changes) {
223
+ for (const c of changes) {
153
224
  lines.push(` ${c.table}: ${describeChange(c)}`);
154
225
  }
155
- if (result.estimatedSeconds !== undefined) {
226
+ if (estimated !== undefined) {
156
227
  lines.push("");
157
- lines.push(` estimated time: ~${String(result.estimatedSeconds)}s`);
228
+ lines.push(` estimated time: ~${String(estimated)}s`);
158
229
  }
159
230
  (0, output_1.emit)(lines.join("\n"));
160
231
  }
161
232
  function describeChange(c) {
162
- if (c.action === "restore_table" || c.action === "drop" || c.action === "create") {
233
+ // 表级操作:dataloom 现在把 schema diff 透出为 action="schema_<diffType>"
234
+ // 老协议的 restore_table / drop / create 一并保留兼容。
235
+ if (c.action === "restore_table") {
236
+ const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
237
+ return `table will be restored${ts}`;
238
+ }
239
+ if (c.action === "drop") {
240
+ const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
241
+ return `table will be dropped${ts}`;
242
+ }
243
+ if (c.action === "create") {
163
244
  const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
164
- if (c.action === "restore_table")
165
- return `table will be restored${ts}`;
166
- if (c.action === "drop")
167
- return `table will be dropped${ts}`;
168
245
  return `table will be created${ts}`;
169
246
  }
247
+ if (c.action?.startsWith("schema_") === true) {
248
+ // schema diff 子类不细化,统一显示 "schema changed (<diffType>)"
249
+ const diffType = c.action.slice("schema_".length);
250
+ return `schema changed${diffType !== "" ? ` (${diffType})` : ""}`;
251
+ }
252
+ if (c.action === "unavailable") {
253
+ // dataloom 端 count 失败的表,复用 droppedAt 透传 message
254
+ return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== "" ? `: ${c.droppedAt}` : ""}`;
255
+ }
256
+ // 数据变更:+N rows / -N rows / ~N rows modified
170
257
  const parts = [];
171
258
  if (c.inserted !== undefined && c.inserted !== 0)
172
259
  parts.push(`+${String(c.inserted)} rows`);
@@ -177,6 +264,28 @@ function describeChange(c) {
177
264
  parts.push(`~${String(c.modified)} rows modified`);
178
265
  return parts.length === 0 ? "no changes" : parts.join(", ");
179
266
  }
267
+ // decorateRecoveryError 给 recovery 路径上的几个错误码补 PRD 规定的 hint。
268
+ // dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
269
+ function decorateRecoveryError(err) {
270
+ if (!(err instanceof error_1.AppError))
271
+ return err;
272
+ switch (err.code) {
273
+ case "DB_API_k_dl_1300036":
274
+ // 窗口超限:引导用户检查 last migration apply 时间
275
+ return new error_1.AppError(err.code, err.message, {
276
+ next_actions: [
277
+ "PITR window is up to 7 days back, limited by your last `db migration apply` time.",
278
+ ],
279
+ });
280
+ case "DB_API_k_dl_1300038":
281
+ // 时间格式错误:引导 ISO 8601
282
+ return new error_1.AppError(err.code, err.message, {
283
+ next_actions: ["Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z"],
284
+ });
285
+ default:
286
+ return err;
287
+ }
288
+ }
180
289
  async function confirm(prompt) {
181
290
  const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
182
291
  return new Promise((resolve) => {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.setRuntimeHttpClient = exports.setHttpClient = exports.resetHttpClient = exports.getRuntimeHttpClient = exports.getHttpClient = exports.log = exports.debug = exports.isJsonMode = exports.emitError = exports.emit = exports.getLogId = exports.generateLogId = exports.initConfigFromOpts = exports.resetConfig = exports.setConfig = exports.getConfig = exports.HttpError = exports.AppError = void 0;
3
+ exports.pollUntilDone = exports.setRuntimeHttpClient = exports.setHttpClient = exports.resetHttpClient = exports.getRuntimeHttpClient = exports.getHttpClient = exports.log = exports.debug = exports.isJsonMode = exports.emitError = exports.emit = exports.getLogId = exports.generateLogId = exports.initConfigFromOpts = exports.resetConfig = exports.setConfig = exports.getConfig = exports.HttpError = exports.AppError = void 0;
4
4
  var error_1 = require("./error");
5
5
  Object.defineProperty(exports, "AppError", { enumerable: true, get: function () { return error_1.AppError; } });
6
6
  Object.defineProperty(exports, "HttpError", { enumerable: true, get: function () { return error_1.HttpError; } });
@@ -25,3 +25,5 @@ Object.defineProperty(exports, "getRuntimeHttpClient", { enumerable: true, get:
25
25
  Object.defineProperty(exports, "resetHttpClient", { enumerable: true, get: function () { return http_1.resetHttpClient; } });
26
26
  Object.defineProperty(exports, "setHttpClient", { enumerable: true, get: function () { return http_1.setHttpClient; } });
27
27
  Object.defineProperty(exports, "setRuntimeHttpClient", { enumerable: true, get: function () { return http_1.setRuntimeHttpClient; } });
28
+ var poll_1 = require("./poll");
29
+ Object.defineProperty(exports, "pollUntilDone", { enumerable: true, get: function () { return poll_1.pollUntilDone; } });
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pollUntilDone = pollUntilDone;
4
+ const error_1 = require("../utils/error");
5
+ async function pollUntilDone(opts) {
6
+ const interval = opts.intervalMs ?? 1000;
7
+ const timeout = opts.timeoutMs ?? 300_000;
8
+ 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
+ });
21
+ }
22
+ await sleep(interval);
23
+ }
24
+ }
25
+ function sleep(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.2-alpha.96b37fa",
3
+ "version": "0.1.2-alpha.9e74f1d",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {