@lark-apaas/miaoda-cli 0.1.2-alpha.4d0ff57 → 0.1.2-alpha.9eeb185

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.
@@ -5,6 +5,7 @@ exports.getSchema = getSchema;
5
5
  exports.importData = importData;
6
6
  exports.exportData = exportData;
7
7
  exports.listDDLChangelog = listDDLChangelog;
8
+ exports.getAuditStatus = getAuditStatus;
8
9
  exports.setAuditConfig = setAuditConfig;
9
10
  exports.migrationInit = migrationInit;
10
11
  exports.migrate = migrate;
@@ -332,13 +333,35 @@ async function listDDLChangelog(opts) {
332
333
  hasMore: Boolean(data.hasMore),
333
334
  };
334
335
  }
335
- // ── db audit → InnerAdminSetAuditConfig ──
336
+ // ── db audit → InnerAdminGetAuditStatus / InnerAdminSetAuditConfig ──
337
+ /**
338
+ * 后端:GET /v1/dataloom/app/{appId}/db/audit/status?table=&dbBranch=
339
+ * 查表审计开关状态。table 非空 → 单表过滤;空 → 返当前 workspace 全部已配置表。
340
+ */
341
+ async function getAuditStatus(opts) {
342
+ const client = (0, http_1.getHttpClient)();
343
+ const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/audit/status", {
344
+ table: opts.table,
345
+ dbBranch: opts.dbBranch,
346
+ });
347
+ const start = Date.now();
348
+ let response;
349
+ try {
350
+ response = await client.get(url);
351
+ (0, client_1.traceHttp)("GET", url, start, response);
352
+ }
353
+ catch (err) {
354
+ (0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
355
+ await mapDbHttpError(err, url, "Failed to get audit status");
356
+ throw err; // 不可达
357
+ }
358
+ const respBody = (await response.json());
359
+ const data = (0, client_1.extractData)(respBody);
360
+ return data.items ?? [];
361
+ }
336
362
  /**
337
363
  * 后端:POST /v1/dataloom/app/{appId}/db/audit/config
338
364
  * 写:enabled=true 开启 / false 关闭。retention 仅 enabled=true 生效。
339
- *
340
- * 读路径(status / log)不在此模块——CLI handler 走 execSql 直接 SELECT
341
- * `_dl_audit_config` / `_dl_audit_log` 元数据表。
342
365
  */
343
366
  async function setAuditConfig(opts) {
344
367
  const client = (0, http_1.getHttpClient)();
@@ -140,7 +140,7 @@ const BIZ_ERR_MAP = new Map(Object.entries({
140
140
  },
141
141
  k_dl_1310003: {
142
142
  code: "INVALID_RETENTION",
143
- hint: "Allowed values: 7d / 30d / 180d / 360d.",
143
+ hint: "Allowed values: 7d, 30d, 180d, 360d, forever.",
144
144
  },
145
145
  // migration
146
146
  k_dl_1320001: {
@@ -1,12 +1,13 @@
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.setAuditConfig = 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.recover = exports.migrate = exports.migrationInit = 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; } });
7
7
  Object.defineProperty(exports, "importData", { enumerable: true, get: function () { return api_1.importData; } });
8
8
  Object.defineProperty(exports, "exportData", { enumerable: true, get: function () { return api_1.exportData; } });
9
9
  Object.defineProperty(exports, "listDDLChangelog", { enumerable: true, get: function () { return api_1.listDDLChangelog; } });
10
+ Object.defineProperty(exports, "getAuditStatus", { enumerable: true, get: function () { return api_1.getAuditStatus; } });
10
11
  Object.defineProperty(exports, "setAuditConfig", { enumerable: true, get: function () { return api_1.setAuditConfig; } });
11
12
  Object.defineProperty(exports, "migrationInit", { enumerable: true, get: function () { return api_1.migrationInit; } });
12
13
  Object.defineProperty(exports, "migrate", { enumerable: true, get: function () { return api_1.migrate; } });
@@ -251,7 +251,7 @@ Examples:
251
251
  .summary("启用表审计")
252
252
  .usage("<table> [flags]")
253
253
  .argument("<table>", "目标表名")
254
- .option("--retention <ttl>", "保留时长 7d / 30d / 180d / 360d", "7d")
254
+ .option("--retention <ttl>", "保留时长 7d / 30d / 180d / 360d / forever", "7d")
255
255
  .action(async function (table) {
256
256
  await (0, index_1.handleDbAuditEnable)(table, this.optsWithGlobals());
257
257
  });
@@ -44,79 +44,71 @@ 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 index_2 = require("../../../api/db/index");
47
- // "forever" 不在后端支持范围(pg_audit ValidDay 只接 int64 天数),CLI 提前拦截
48
- const VALID_RETENTION = new Set(["7d", "30d", "180d", "360d"]);
47
+ const VALID_RETENTION = new Set(["7d", "30d", "180d", "360d", "forever"]);
49
48
  async function handleDbAuditStatus(table, opts) {
50
49
  const appId = (0, shared_1.resolveAppId)(opts);
51
- const sql = table !== undefined && table !== ""
52
- ? `SELECT "table", enabled, enabled_at, retention FROM _dl_audit_config WHERE "table" = '${escapeSqlLiteral(table)}'`
53
- : `SELECT "table", enabled, enabled_at, retention FROM _dl_audit_config ORDER BY "table"`;
54
- const results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
55
- const rows = collectSelectRows(results);
56
- // 单表查询:表未配置时给个 enabled=false 占位项,让 CLI 输出语义统一
50
+ const items = await api.db.getAuditStatus({ appId, table, dbBranch: opts.env });
51
+ const rows = items.map(toAuditStatus);
52
+ // 单表查询但后端没记录 占位 enabled=false
57
53
  if (table !== undefined && table !== "" && rows.length === 0) {
58
- rows.push({ table, enabled: false });
54
+ rows.push({ table, enabled: false, enabled_at: null, retention: null });
59
55
  }
60
- const items = rows.map(toAuditStatus);
56
+ // PRD JSON:单表返 object,多表返 array
61
57
  if ((0, output_1.isJsonMode)()) {
62
- (0, output_1.emitOk)({ items });
58
+ if (table !== undefined && rows.length === 1) {
59
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(rows[0]));
60
+ }
61
+ else {
62
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(rows));
63
+ }
63
64
  return;
64
65
  }
65
- if (items.length === 0) {
66
+ if (rows.length === 0) {
66
67
  (0, output_1.emit)("No audit configuration found.");
67
68
  return;
68
69
  }
69
70
  const tty = (0, render_1.isStdoutTty)();
70
- // 单表显式查询(rows.length=1 且对应输入 table)走 key/value 形态,更可读
71
- if (table !== undefined && items.length === 1) {
72
- const it = items[0];
71
+ // 单表 key:value 形态
72
+ if (table !== undefined && rows.length === 1) {
73
+ const it = rows[0];
73
74
  (0, output_1.emit)((0, render_1.renderKeyValue)([
74
- ["table", it.table],
75
- ["enabled", String(it.enabled)],
76
- ["enabled_at", it.enabled_at ? (0, render_1.formatTime)(it.enabled_at, tty) : "—"],
77
- ["retention", it.retention ?? "—"],
75
+ ["Table", it.table],
76
+ ["Enabled", boolToYesNo(it.enabled)],
77
+ ["Enabled at", it.enabled_at ? (0, render_1.formatTime)(it.enabled_at, tty) : "—"],
78
+ ["Retention", it.retention ?? "—"],
78
79
  ], tty));
79
80
  return;
80
81
  }
82
+ // 列表 → table 形态
81
83
  const headers = ["table", "enabled", "enabled_at", "retention"];
82
- const out = items.map((it) => [
84
+ const out = rows.map((it) => [
83
85
  it.table,
84
- String(it.enabled),
86
+ boolToYesNo(it.enabled),
85
87
  it.enabled_at ? (0, render_1.formatTime)(it.enabled_at, tty) : "—",
86
88
  it.retention ?? "—",
87
89
  ]);
88
90
  (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, out) : (0, render_1.renderTsv)(headers, out));
89
91
  }
90
- function toAuditStatus(row) {
92
+ function toAuditStatus(s) {
91
93
  return {
92
- table: asString(row.table),
93
- enabled: Boolean(row.enabled),
94
- enabled_at: row.enabled_at == null ? null : asString(row.enabled_at),
95
- retention: row.retention == null ? null : asString(row.retention),
94
+ table: s.table,
95
+ enabled: s.enabled,
96
+ enabled_at: s.enabledAt ?? null,
97
+ retention: s.retention ?? null,
96
98
  };
97
99
  }
98
- // asString 把 SELECT 结果里的列值(unknown)安全地转成字符串。SQL 返回值通常是
99
- // string / number / boolean / null,但极端情况下也可能是 JSON 反序列化出的对象。
100
- // 直接 String(obj) 会得到 "[object Object]",这里改用 typeof 分类避免。
101
- function asString(v, fallback = "") {
102
- if (v == null)
103
- return fallback;
104
- if (typeof v === "string")
105
- return v;
106
- if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
107
- return String(v);
108
- return JSON.stringify(v);
109
- }
110
100
  async function handleDbAuditEnable(table, opts) {
111
101
  if (!table) {
112
102
  throw new error_1.AppError("ARGS_INVALID", "table name is required", {
113
- next_actions: ["Usage: miaoda db audit enable <table> [--retention 7d|30d|180d|360d]"],
103
+ next_actions: [
104
+ "Usage: miaoda db audit enable <table> [--retention 7d|30d|180d|360d|forever]",
105
+ ],
114
106
  });
115
107
  }
116
108
  const retention = opts.retention ?? "7d";
117
109
  if (!VALID_RETENTION.has(retention)) {
118
- throw new error_1.AppError("INVALID_RETENTION", `invalid retention: ${retention}`, {
119
- next_actions: ["Allowed values: 7d / 30d / 180d / 360d."],
110
+ throw new error_1.AppError("INVALID_RETENTION", `Invalid retention '${retention}'`, {
111
+ next_actions: ["Allowed values: 7d, 30d, 180d, 360d, forever."],
120
112
  });
121
113
  }
122
114
  const appId = (0, shared_1.resolveAppId)(opts);
@@ -127,11 +119,18 @@ async function handleDbAuditEnable(table, opts) {
127
119
  retention,
128
120
  dbBranch: opts.env,
129
121
  });
122
+ // PRD JSON:{"data": {"table": "...", "enabled": true, "retention": "..."}}
130
123
  if ((0, output_1.isJsonMode)()) {
131
- (0, output_1.emitOk)({ status });
124
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
125
+ table: status.table,
126
+ enabled: status.enabled,
127
+ retention: status.retention ?? retention,
128
+ }));
132
129
  return;
133
130
  }
134
- (0, output_1.emit)(`✓ Audit enabled for '${status.table}' (retention=${status.retention ?? retention})`);
131
+ const tty = (0, render_1.isStdoutTty)();
132
+ const prefix = tty ? "✓" : "OK";
133
+ (0, output_1.emit)(`${prefix} Audit enabled for table '${status.table}' (retention: ${status.retention ?? retention})`);
135
134
  }
136
135
  async function handleDbAuditDisable(table, opts) {
137
136
  if (!table) {
@@ -147,10 +146,15 @@ async function handleDbAuditDisable(table, opts) {
147
146
  dbBranch: opts.env,
148
147
  });
149
148
  if ((0, output_1.isJsonMode)()) {
150
- (0, output_1.emitOk)({ status });
149
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
150
+ table: status.table,
151
+ enabled: status.enabled,
152
+ }));
151
153
  return;
152
154
  }
153
- (0, output_1.emit)(`✓ Audit disabled for '${status.table}'`);
155
+ const tty = (0, render_1.isStdoutTty)();
156
+ const prefix = tty ? "✓" : "OK";
157
+ (0, output_1.emit)(`${prefix} Audit disabled for table '${status.table}'`);
154
158
  }
155
159
  async function handleDbAuditList(tables, opts) {
156
160
  if (tables.length === 0) {
@@ -161,7 +165,7 @@ async function handleDbAuditList(tables, opts) {
161
165
  });
162
166
  }
163
167
  const appId = (0, shared_1.resolveAppId)(opts);
164
- const limit = opts.limit ?? 50;
168
+ const limit = opts.limit ?? 20;
165
169
  // since 优先级:opts.cursor(前一页末条 event_time)> opts.since
166
170
  const sinceRaw = opts.cursor ?? opts.since;
167
171
  const since = normalizeTime(sinceRaw, "--since/--cursor");
@@ -176,21 +180,29 @@ async function handleDbAuditList(tables, opts) {
176
180
  `FROM _dl_audit_log WHERE ${whereParts.join(" AND ")} ORDER BY event_time DESC LIMIT ${String(limit + 1)}`;
177
181
  const results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
178
182
  const allRows = collectSelectRows(results);
179
- // 多取一条用于判断 hasMore;最终只展示 limit 行
180
183
  const hasMore = allRows.length > limit;
181
184
  const visible = hasMore ? allRows.slice(0, limit) : allRows;
182
185
  const nextCursor = hasMore && visible.length > 0 ? asString(visible[visible.length - 1].event_time) : null;
183
- // 多表混合时统计哪些表无记录,PRD 要求末尾汇总
186
+ // 多表混合时统计哪些表无记录
184
187
  const seen = new Set();
185
188
  for (const r of visible)
186
189
  seen.add(asString(r.target_table));
187
190
  const skipped = tables.filter((t) => !seen.has(t));
191
+ // PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
188
192
  if ((0, output_1.isJsonMode)()) {
189
- (0, output_1.emitPaged)(visible, nextCursor, hasMore);
193
+ const items = visible.map((r) => ({
194
+ event_id: asString(r.event_id),
195
+ event_time: asString(r.event_time),
196
+ target_table: asString(r.target_table),
197
+ type: asString(r.type),
198
+ operator: asString(r.operator),
199
+ summary: asString(r.summary),
200
+ details: r.details, // 已经是结构化 JSON,透传
201
+ }));
202
+ (0, output_1.emitPaged)(items, nextCursor, hasMore);
190
203
  if (skipped.length > 0) {
191
204
  process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
192
205
  }
193
- // 退出码:任一表有数据 → 0;全空 → 1(PRD 约定)
194
206
  if (visible.length === 0)
195
207
  process.exitCode = 1;
196
208
  return;
@@ -204,14 +216,23 @@ async function handleDbAuditList(tables, opts) {
204
216
  return;
205
217
  }
206
218
  const tty = (0, render_1.isStdoutTty)();
207
- const headers = ["event_time", "target_table", "type", "operator", "summary"];
208
- const rows = visible.map((it) => [
209
- (0, render_1.formatTime)(asString(it.event_time), tty),
210
- asString(it.target_table),
211
- asString(it.type),
212
- asString(it.operator, "—"),
213
- asString(it.summary),
214
- ]);
219
+ // PRD:多表渲染时第一列是 target_table,单表时去掉这一列
220
+ const isMultiTable = tables.length > 1;
221
+ const headers = isMultiTable
222
+ ? ["target_table", "event_time", "type", "event_id", "operator", "summary"]
223
+ : ["event_time", "type", "event_id", "operator", "summary"];
224
+ const rows = visible.map((it) => {
225
+ const eventTime = (0, render_1.formatTime)(asString(it.event_time), tty);
226
+ const eventId = truncateId(asString(it.event_id));
227
+ const cells = [
228
+ eventTime,
229
+ asString(it.type),
230
+ eventId,
231
+ asString(it.operator, "—"),
232
+ asString(it.summary),
233
+ ];
234
+ return isMultiTable ? [asString(it.target_table), ...cells] : cells;
235
+ });
215
236
  (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
216
237
  if (skipped.length > 0) {
217
238
  process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
@@ -224,8 +245,7 @@ async function handleDbAuditList(tables, opts) {
224
245
  function normalizeTime(input, flagName) {
225
246
  if (input === undefined || input === "")
226
247
  return undefined;
227
- // ISO 8601 形如 2026-05-07T08:00:00Z 直接透传,避免 parseTimeFilterMs
228
- // 严格 ISO 上重做一次会损精度
248
+ // ISO 8601 直接透传,避免 parseTimeFilterMs 在严格 ISO 上重做一次损精度
229
249
  if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
230
250
  return new Date(input).toISOString();
231
251
  }
@@ -241,10 +261,26 @@ function collectSelectRows(results) {
241
261
  }
242
262
  return out;
243
263
  }
244
- /**
245
- * SQL 字符串字面量转义。仅处理单引号;表名 / 时间戳由调用方控制范围(白名单或
246
- * 严格 ISO 8601 正则),不在此函数内做。
247
- */
264
+ // SQL 字面量转义。表名 / 时间戳由调用方控制范围(白名单或严格 ISO 8601 正则)。
248
265
  function escapeSqlLiteral(s) {
249
266
  return s.replace(/'/g, "''");
250
267
  }
268
+ // PRD 输出 enabled 列用 yes/no 而非 true/false
269
+ function boolToYesNo(b) {
270
+ return b ? "yes" : "no";
271
+ }
272
+ function asString(v, fallback = "") {
273
+ if (v == null)
274
+ return fallback;
275
+ if (typeof v === "string")
276
+ return v;
277
+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
278
+ return String(v);
279
+ return JSON.stringify(v);
280
+ }
281
+ // audit list 的 event_id 在 TTY 视图里截断展示(PRD 示例 "01525416B44F...")
282
+ function truncateId(id) {
283
+ if (id.length <= 12)
284
+ return id;
285
+ return id.slice(0, 12) + "...";
286
+ }
@@ -39,6 +39,17 @@ const index_1 = require("../../../api/file/index");
39
39
  const shared_1 = require("../../../cli/commands/shared");
40
40
  const output_1 = require("../../../utils/output");
41
41
  const render_1 = require("../../../utils/render");
42
+ function toRow(it) {
43
+ return {
44
+ change_id: it.changeId,
45
+ changed_at: it.changedAt,
46
+ operator: it.operator,
47
+ target_table: it.targetTable,
48
+ change_type: it.changeType,
49
+ summary: it.summary,
50
+ statement: it.statement,
51
+ };
52
+ }
42
53
  /** 把用户传入的 since/until 归一成 ISO 8601 UTC 字符串;空串返 undefined。 */
43
54
  function normalizeTime(input, flagName) {
44
55
  if (input === undefined || input === "")
@@ -74,12 +85,13 @@ async function handleDbChangelog(opts) {
74
85
  cursor = page.nextCursor;
75
86
  }
76
87
  if ((0, output_1.isJsonMode)()) {
88
+ const rows = allItems.map(toRow);
77
89
  // --all 时已经把所有页合一起返,has_more=false / next_cursor=null
78
90
  if (opts.all) {
79
- (0, output_1.emitPaged)(allItems, null, false);
91
+ (0, output_1.emitPaged)((0, output_1.snakeCaseKeys)(rows), null, false);
80
92
  }
81
93
  else {
82
- (0, output_1.emitPaged)(allItems, lastCursor, lastHasMore);
94
+ (0, output_1.emitPaged)((0, output_1.snakeCaseKeys)(rows), lastCursor, lastHasMore);
83
95
  }
84
96
  return;
85
97
  }
@@ -88,7 +100,8 @@ async function handleDbChangelog(opts) {
88
100
  return;
89
101
  }
90
102
  const tty = (0, render_1.isStdoutTty)();
91
- const headers = ["change_id", "changed_at", "operator", "target_table", "type", "summary"];
103
+ // PRD: change_id / changed_at / operator / target_table / change_type / summary
104
+ const headers = ["change_id", "changed_at", "operator", "target_table", "change_type", "summary"];
92
105
  const rows = allItems.map((it) => [
93
106
  it.changeId,
94
107
  (0, render_1.formatTime)(it.changedAt, tty),
@@ -46,7 +46,7 @@ const output_1 = require("../../../utils/output");
46
46
  const render_1 = require("../../../utils/render");
47
47
  async function handleDbMigrationInit(opts) {
48
48
  const appId = (0, shared_1.resolveAppId)(opts);
49
- // PRD:不可逆操作,TTY 默认要求 y/N;--yes 跳过
49
+ // 不可逆操作,TTY 默认要求 y/N;--yes 跳过
50
50
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
51
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) `);
52
52
  if (!ok) {
@@ -56,48 +56,73 @@ async function handleDbMigrationInit(opts) {
56
56
  }
57
57
  const result = await api.db.migrationInit({ appId, syncData: opts.syncData });
58
58
  if ((0, output_1.isJsonMode)()) {
59
- (0, output_1.emitOk)(result);
59
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(result));
60
60
  return;
61
61
  }
62
62
  const tty = (0, render_1.isStdoutTty)();
63
63
  (0, output_1.emit)((0, render_1.renderKeyValue)([
64
- ["status", result.status],
65
- ["environments", result.environments.join(", ")],
66
- ["data_synced", String(result.dataSynced)],
64
+ ["Status", result.status],
65
+ ["Environments", result.environments.join(", ")],
66
+ ["Data synced", String(result.dataSynced)],
67
67
  ], tty));
68
68
  }
69
69
  async function handleDbMigrationDiff(opts) {
70
70
  const appId = (0, shared_1.resolveAppId)(opts);
71
71
  const result = await api.db.migrate({ appId, dryRun: true });
72
- renderMigrate(result);
72
+ renderDiff(result);
73
73
  }
74
74
  async function handleDbMigrationApply(opts) {
75
75
  const appId = (0, shared_1.resolveAppId)(opts);
76
- // diff 一次给用户确认
77
- const preview = await api.db.migrate({ appId, dryRun: true });
78
- if (preview.changes.length === 0) {
79
- if ((0, output_1.isJsonMode)()) {
80
- (0, output_1.emitOk)({ ...preview, status: "no_pending_changes" });
81
- }
82
- else {
83
- (0, output_1.emit)("No pending changes from dev to online.");
84
- }
85
- return;
86
- }
76
+ // TTY 下先 diff 给用户审;--yes 直接打到 online
87
77
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
88
- renderMigrate(preview);
89
- const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) from ${preview.from} to ${preview.to}? (y/N) `);
78
+ const preview = await api.db.migrate({ appId, dryRun: true });
79
+ 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;
84
+ }
85
+ renderDiff(preview);
86
+ const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
90
87
  if (!ok) {
91
88
  (0, output_1.emit)("Aborted.");
92
89
  return;
93
90
  }
94
91
  }
95
92
  const result = await api.db.migrate({ appId, dryRun: false });
96
- renderMigrate(result);
93
+ if ((0, output_1.isJsonMode)()) {
94
+ // PRD:{"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}
95
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
96
+ status: result.status ?? "applied",
97
+ from: result.from,
98
+ to: result.to,
99
+ changesApplied: result.changesApplied ?? result.changes.length,
100
+ }));
101
+ return;
102
+ }
103
+ const tty = (0, render_1.isStdoutTty)();
104
+ const prefix = tty ? "✓" : "OK";
105
+ 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)`);
97
108
  }
98
- function renderMigrate(result) {
109
+ // ── helpers ──
110
+ // PRD diff 输出:
111
+ // dev → online (2 changes):
112
+ //
113
+ // ALTER TABLE users ADD COLUMN avatar_url text;
114
+ // CREATE INDEX idx_users_avatar ON users(avatar_url);
115
+ function renderDiff(result) {
99
116
  if ((0, output_1.isJsonMode)()) {
100
- (0, output_1.emitOk)(result);
117
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
118
+ from: result.from,
119
+ to: result.to,
120
+ changes: result.changes.map((c) => ({
121
+ type: c.type,
122
+ table: c.table,
123
+ statement: c.statement,
124
+ })),
125
+ }));
101
126
  return;
102
127
  }
103
128
  const tty = (0, render_1.isStdoutTty)();
@@ -105,17 +130,10 @@ function renderMigrate(result) {
105
130
  (0, output_1.emit)(`No pending changes from ${result.from} to ${result.to}.`);
106
131
  return;
107
132
  }
108
- const headers = ["type", "table", "statement"];
109
- const rows = result.changes.map((c) => [c.type, c.table, c.statement]);
110
- (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
111
- if (result.dryRun) {
112
- (0, output_1.emit)(`(dry-run: ${String(result.changes.length)} change(s) preview, run \`db migration apply\` to commit)`);
113
- }
114
- else {
115
- (0, output_1.emit)(`✓ Applied ${String(result.changesApplied ?? result.changes.length)} change(s) from ${result.from} to ${result.to}`);
116
- }
133
+ const arrow = tty ? "" : "->";
134
+ (0, output_1.emit)(`${result.from} ${arrow} ${result.to} (${String(result.changes.length)} changes):\n\n` +
135
+ result.changes.map((c) => ` ${c.statement}`).join("\n"));
117
136
  }
118
- // ── confirm ──
119
137
  async function confirm(prompt) {
120
138
  const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
121
139
  return new Promise((resolve) => {
@@ -42,19 +42,27 @@ async function handleDbQuota(opts) {
42
42
  const appId = (0, shared_1.resolveAppId)(opts);
43
43
  const data = await api.db.getDbQuota({ appId, dbBranch: opts.env });
44
44
  if ((0, output_1.isJsonMode)()) {
45
- (0, output_1.emitOk)(data);
45
+ // 配额未对接(storageQuotaBytes=0)时,quota / usage_percent 字段都不输出
46
+ const out = {
47
+ storageUsedBytes: data.storageUsedBytes,
48
+ tables: data.tables,
49
+ views: data.views,
50
+ };
51
+ if (data.storageQuotaBytes > 0) {
52
+ out.storageQuotaBytes = data.storageQuotaBytes;
53
+ out.usagePercent = data.usagePercent;
54
+ }
55
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(out));
46
56
  return;
47
57
  }
58
+ // PRD:单行 "Storage: 14.9 MB / 1 GB (1.5%)";配额未对接时只显示 used
48
59
  const tty = (0, render_1.isStdoutTty)();
49
- // quota=0 表示后端未对接配额上限,按 PRD 渲染 "—"
50
- const used = (0, render_1.formatSize)(data.storageUsedBytes);
51
- const total = data.storageQuotaBytes > 0 ? (0, render_1.formatSize)(data.storageQuotaBytes) : "—";
52
- const pct = data.storageQuotaBytes > 0 ? `${data.usagePercent.toFixed(1)}%` : "—";
60
+ const storageLine = data.storageQuotaBytes > 0
61
+ ? `${(0, render_1.formatSize)(data.storageUsedBytes)} / ${(0, render_1.formatSize)(data.storageQuotaBytes)} (${data.usagePercent.toFixed(1)}%)`
62
+ : (0, render_1.formatSize)(data.storageUsedBytes);
53
63
  (0, output_1.emit)((0, render_1.renderKeyValue)([
54
- ["used", used],
55
- ["quota", total],
56
- ["usage", pct],
57
- ["tables", String(data.tables)],
58
- ["views", String(data.views)],
64
+ ["Storage", storageLine],
65
+ ["Tables", String(data.tables)],
66
+ ["Views", String(data.views)],
59
67
  ], tty));
60
68
  }
@@ -49,23 +49,36 @@ async function handleDbRecoveryDiff(target, opts) {
49
49
  const appId = (0, shared_1.resolveAppId)(opts);
50
50
  const ts = normalizeTimestamp(target);
51
51
  const result = await api.db.recover({ appId, target: ts, dryRun: true });
52
- renderRecover(result);
52
+ renderDiff(result);
53
53
  }
54
54
  async function handleDbRecoveryApply(target, opts) {
55
55
  const appId = (0, shared_1.resolveAppId)(opts);
56
56
  const ts = normalizeTimestamp(target);
57
- // PITR 高危:apply 之前先强制 diff 给用户审;--yes 才能跳过
58
- const preview = await api.db.recover({ appId, target: ts, dryRun: true });
57
+ // PITR 高危:TTY 下先 diff 给用户审;--yes 直接执行
59
58
  if (!opts.yes && !(0, output_1.isJsonMode)()) {
60
- renderRecover(preview);
61
- const ok = await confirm(`? Restore ${String(preview.tablesAffected)} table(s) to ${preview.target}? This is IRREVERSIBLE. (y/N) `);
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) `);
62
62
  if (!ok) {
63
63
  (0, output_1.emit)("Aborted.");
64
64
  return;
65
65
  }
66
66
  }
67
67
  const result = await api.db.recover({ appId, target: ts, dryRun: false });
68
- renderRecover(result);
68
+ if ((0, output_1.isJsonMode)()) {
69
+ // PRD:{"status": "restored", "target": "...", "tables_affected": 2, "elapsed_seconds": 30}
70
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
71
+ status: result.status ?? "restored",
72
+ target: result.target,
73
+ tablesAffected: result.tablesAffected,
74
+ elapsedSeconds: result.elapsedSeconds ?? 0,
75
+ }));
76
+ return;
77
+ }
78
+ const tty = (0, render_1.isStdoutTty)();
79
+ 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)`);
69
82
  }
70
83
  // ── helpers ──
71
84
  /**
@@ -79,56 +92,90 @@ function normalizeTimestamp(input) {
79
92
  next_actions: ["Usage: miaoda db recovery diff|apply <timestamp>"],
80
93
  });
81
94
  }
82
- // YYYY-MM-DD:按 UTC 00:00:00
83
95
  if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
84
96
  return new Date(`${input}T00:00:00Z`).toISOString();
85
97
  }
86
- // YYYY-MM-DD HH:MM[:SS]:本地时间,让 Date 自己解析
87
98
  if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(input)) {
88
99
  const d = new Date(input.replace(" ", "T"));
89
100
  if (Number.isNaN(d.getTime())) {
90
- throw new error_1.AppError("INVALID_TIMESTAMP", `invalid timestamp: ${input}`, {
101
+ throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
91
102
  next_actions: [FORMAT_HINT],
92
103
  });
93
104
  }
94
105
  return d.toISOString();
95
106
  }
96
- // 完整 ISO 8601
97
107
  const d = new Date(input);
98
108
  if (Number.isNaN(d.getTime())) {
99
- throw new error_1.AppError("INVALID_TIMESTAMP", `invalid timestamp: ${input}`, {
109
+ throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
100
110
  next_actions: [FORMAT_HINT],
101
111
  });
102
112
  }
103
113
  return d.toISOString();
104
114
  }
105
- function renderRecover(result) {
115
+ // PRD diff 输出(结构化 prose):
116
+ // Recovery preview (→ 2026-04-15T10:00:00Z):
117
+ //
118
+ // tables affected: 2
119
+ // users: +3 rows, -1 row, ~5 rows modified
120
+ // orders: table will be restored (was dropped at 10:25:00)
121
+ //
122
+ // estimated time: ~30s
123
+ function renderDiff(result) {
106
124
  if ((0, output_1.isJsonMode)()) {
107
- (0, output_1.emitOk)(result);
125
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
126
+ target: result.target,
127
+ tablesAffected: result.tablesAffected,
128
+ changes: result.changes.map((c) => ({
129
+ table: c.table,
130
+ inserted: c.inserted,
131
+ deleted: c.deleted,
132
+ modified: c.modified,
133
+ action: c.action,
134
+ droppedAt: c.droppedAt,
135
+ })),
136
+ estimatedSeconds: result.estimatedSeconds ?? 0,
137
+ }));
108
138
  return;
109
139
  }
110
140
  const tty = (0, render_1.isStdoutTty)();
141
+ const arrow = tty ? "→" : "->";
111
142
  if (result.changes.length === 0) {
112
- (0, output_1.emit)(`No tables affected at ${result.target}.`);
143
+ (0, output_1.emit)(`Recovery preview (${arrow} ${result.target}):\n\n` +
144
+ ` No changes — database is already at this state.`);
113
145
  return;
114
146
  }
115
- const headers = ["table", "action", "inserted", "deleted", "modified", "dropped_at"];
116
- const rows = result.changes.map((c) => [
117
- c.table,
118
- c.action ?? "—",
119
- c.inserted !== undefined ? String(c.inserted) : "—",
120
- c.deleted !== undefined ? String(c.deleted) : "—",
121
- c.modified !== undefined ? String(c.modified) : "—",
122
- c.droppedAt ?? "—",
123
- ]);
124
- (0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
125
- if (result.dryRun) {
126
- const eta = result.estimatedSeconds !== undefined ? `, eta ~${String(result.estimatedSeconds)}s` : "";
127
- (0, output_1.emit)(`(dry-run: ${String(result.tablesAffected)} table(s) affected${eta}; run \`db recovery apply\` to commit)`);
147
+ const lines = [
148
+ `Recovery preview (${arrow} ${result.target}):`,
149
+ "",
150
+ ` tables affected: ${String(result.tablesAffected)}`,
151
+ ];
152
+ for (const c of result.changes) {
153
+ lines.push(` ${c.table}: ${describeChange(c)}`);
154
+ }
155
+ if (result.estimatedSeconds !== undefined) {
156
+ lines.push("");
157
+ lines.push(` estimated time: ~${String(result.estimatedSeconds)}s`);
158
+ }
159
+ (0, output_1.emit)(lines.join("\n"));
160
+ }
161
+ function describeChange(c) {
162
+ if (c.action === "restore_table" || c.action === "drop" || c.action === "create") {
163
+ 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
+ return `table will be created${ts}`;
128
169
  }
129
- else {
130
- (0, output_1.emit)(`✓ Restored ${String(result.tablesAffected)} table(s) to ${result.target} in ${String(result.elapsedSeconds ?? 0)}s`);
170
+ const parts = [];
171
+ if (c.inserted !== undefined && c.inserted !== 0)
172
+ parts.push(`+${String(c.inserted)} rows`);
173
+ if (c.deleted !== undefined && c.deleted !== 0) {
174
+ parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? "row" : "rows"}`);
131
175
  }
176
+ if (c.modified !== undefined && c.modified !== 0)
177
+ parts.push(`~${String(c.modified)} rows modified`);
178
+ return parts.length === 0 ? "no changes" : parts.join(", ");
132
179
  }
133
180
  async function confirm(prompt) {
134
181
  const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
@@ -42,17 +42,25 @@ async function handleFileQuota(opts) {
42
42
  const appId = (0, shared_1.resolveAppId)(opts);
43
43
  const data = await api.file.getStorageQuota({ appId });
44
44
  if ((0, output_1.isJsonMode)()) {
45
- (0, output_1.emitOk)(data);
45
+ // 配额未对接(storageQuotaBytes=0)时,quota / usage_percent 字段都不输出
46
+ const out = {
47
+ storageUsedBytes: data.storageUsedBytes,
48
+ files: data.files,
49
+ };
50
+ if (data.storageQuotaBytes > 0) {
51
+ out.storageQuotaBytes = data.storageQuotaBytes;
52
+ out.usagePercent = data.usagePercent;
53
+ }
54
+ (0, output_1.emitOk)((0, output_1.snakeCaseKeys)(out));
46
55
  return;
47
56
  }
57
+ // PRD:单行 "Storage: 150 MB / 1 GB (15%)";配额未对接时只显示 used
48
58
  const tty = (0, render_1.isStdoutTty)();
49
- const used = (0, render_1.formatSize)(data.storageUsedBytes);
50
- const total = data.storageQuotaBytes > 0 ? (0, render_1.formatSize)(data.storageQuotaBytes) : "—";
51
- const pct = data.storageQuotaBytes > 0 ? `${data.usagePercent.toFixed(1)}%` : "—";
59
+ const storageLine = data.storageQuotaBytes > 0
60
+ ? `${(0, render_1.formatSize)(data.storageUsedBytes)} / ${(0, render_1.formatSize)(data.storageQuotaBytes)} (${data.usagePercent.toFixed(1)}%)`
61
+ : (0, render_1.formatSize)(data.storageUsedBytes);
52
62
  (0, output_1.emit)((0, render_1.renderKeyValue)([
53
- ["used", used],
54
- ["quota", total],
55
- ["usage", pct],
56
- ["files", String(data.files)],
63
+ ["Storage", storageLine],
64
+ ["Files", String(data.files)],
57
65
  ], tty));
58
66
  }
@@ -5,6 +5,7 @@ exports.emit = emit;
5
5
  exports.emitError = emitError;
6
6
  exports.emitOk = emitOk;
7
7
  exports.emitPaged = emitPaged;
8
+ exports.snakeCaseKeys = snakeCaseKeys;
8
9
  const config_1 = require("./config");
9
10
  const error_1 = require("./error");
10
11
  const colors_1 = require("./colors");
@@ -118,6 +119,27 @@ function emitOk(data) {
118
119
  function emitPaged(items, nextCursor, hasMore) {
119
120
  emit({ data: items, next_cursor: nextCursor, has_more: hasMore });
120
121
  }
122
+ /**
123
+ * 把对象 / 数组里所有字符串 key 从 camelCase 转成 snake_case,递归处理嵌套对象。
124
+ * 后端 IDL 返回的 JSON 字段是 camelCase(`storageUsedBytes`),CLI 对外(PRD)
125
+ * 统一 snake_case(`storage_used_bytes`);emit JSON 前过一遍这个函数。
126
+ */
127
+ function snakeCaseKeys(input) {
128
+ if (Array.isArray(input)) {
129
+ return input.map((item) => snakeCaseKeys(item));
130
+ }
131
+ if (input !== null && typeof input === "object") {
132
+ const out = {};
133
+ for (const [k, v] of Object.entries(input)) {
134
+ out[camelToSnake(k)] = snakeCaseKeys(v);
135
+ }
136
+ return out;
137
+ }
138
+ return input;
139
+ }
140
+ function camelToSnake(s) {
141
+ return s.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase());
142
+ }
121
143
  function toErrorInfo(err) {
122
144
  if (err instanceof error_1.AppError)
123
145
  return err.toJSON();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.2-alpha.4d0ff57",
3
+ "version": "0.1.2-alpha.9eeb185",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {