@lark-apaas/miaoda-cli 0.1.2-alpha.4d0ff57 → 0.1.2-alpha.746290f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/db/client.js +1 -1
- package/dist/cli/commands/db/index.js +1 -1
- package/dist/cli/handlers/db/audit.js +115 -49
- package/dist/cli/handlers/db/changelog.js +16 -3
- package/dist/cli/handlers/db/migration.js +50 -32
- package/dist/cli/handlers/db/quota.js +8 -10
- package/dist/cli/handlers/db/recovery.js +76 -29
- package/dist/cli/handlers/file/quota.js +7 -8
- package/dist/utils/output.js +22 -0
- package/package.json +1 -1
package/dist/api/db/client.js
CHANGED
|
@@ -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
|
|
143
|
+
hint: "Allowed values: 7d, 30d, 180d, 360d, forever.",
|
|
144
144
|
},
|
|
145
145
|
// migration
|
|
146
146
|
k_dl_1320001: {
|
|
@@ -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,8 +44,7 @@ const error_1 = require("../../../utils/error");
|
|
|
44
44
|
const output_1 = require("../../../utils/output");
|
|
45
45
|
const render_1 = require("../../../utils/render");
|
|
46
46
|
const index_2 = require("../../../api/db/index");
|
|
47
|
-
|
|
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
50
|
const sql = table !== undefined && table !== ""
|
|
@@ -53,13 +52,19 @@ async function handleDbAuditStatus(table, opts) {
|
|
|
53
52
|
: `SELECT "table", enabled, enabled_at, retention FROM _dl_audit_config ORDER BY "table"`;
|
|
54
53
|
const results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
|
|
55
54
|
const rows = collectSelectRows(results);
|
|
56
|
-
//
|
|
55
|
+
// 单表查询且 _dl_audit_config 没记录 → enabled=false 占位
|
|
57
56
|
if (table !== undefined && table !== "" && rows.length === 0) {
|
|
58
57
|
rows.push({ table, enabled: false });
|
|
59
58
|
}
|
|
60
59
|
const items = rows.map(toAuditStatus);
|
|
60
|
+
// PRD JSON:单表返 object,多表返 array
|
|
61
61
|
if ((0, output_1.isJsonMode)()) {
|
|
62
|
-
|
|
62
|
+
if (table !== undefined && items.length === 1) {
|
|
63
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)(items[0]));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)(items));
|
|
67
|
+
}
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
if (items.length === 0) {
|
|
@@ -67,56 +72,59 @@ async function handleDbAuditStatus(table, opts) {
|
|
|
67
72
|
return;
|
|
68
73
|
}
|
|
69
74
|
const tty = (0, render_1.isStdoutTty)();
|
|
70
|
-
//
|
|
75
|
+
// 单表 → key:value 形态(PRD 示例)
|
|
71
76
|
if (table !== undefined && items.length === 1) {
|
|
72
77
|
const it = items[0];
|
|
73
78
|
(0, output_1.emit)((0, render_1.renderKeyValue)([
|
|
74
|
-
["
|
|
75
|
-
["
|
|
76
|
-
["
|
|
77
|
-
["
|
|
79
|
+
["Table", it.table],
|
|
80
|
+
["Enabled", boolToYesNo(it.enabled)],
|
|
81
|
+
["Enabled at", it.enabled_at ? (0, render_1.formatTime)(it.enabled_at, tty) : "—"],
|
|
82
|
+
["Retention", it.retention ?? "—"],
|
|
78
83
|
], tty));
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
86
|
+
// 列表 → table 形态
|
|
81
87
|
const headers = ["table", "enabled", "enabled_at", "retention"];
|
|
82
88
|
const out = items.map((it) => [
|
|
83
89
|
it.table,
|
|
84
|
-
|
|
90
|
+
boolToYesNo(it.enabled),
|
|
85
91
|
it.enabled_at ? (0, render_1.formatTime)(it.enabled_at, tty) : "—",
|
|
86
92
|
it.retention ?? "—",
|
|
87
93
|
]);
|
|
88
94
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, out) : (0, render_1.renderTsv)(headers, out));
|
|
89
95
|
}
|
|
90
96
|
function toAuditStatus(row) {
|
|
97
|
+
// retention 列存的是后端 ValidDay (int64 天数),按 sentinel 反向映射成 PRD 字符串
|
|
98
|
+
const rawRetention = row.retention;
|
|
99
|
+
let retention = null;
|
|
100
|
+
if (rawRetention != null) {
|
|
101
|
+
if (typeof rawRetention === "number") {
|
|
102
|
+
retention = retentionDaysToString(rawRetention);
|
|
103
|
+
}
|
|
104
|
+
else if (typeof rawRetention === "string") {
|
|
105
|
+
// 兼容存的就是字符串形态("7d" / "forever")
|
|
106
|
+
retention = rawRetention;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
91
109
|
return {
|
|
92
110
|
table: asString(row.table),
|
|
93
111
|
enabled: Boolean(row.enabled),
|
|
94
112
|
enabled_at: row.enabled_at == null ? null : asString(row.enabled_at),
|
|
95
|
-
retention
|
|
113
|
+
retention,
|
|
96
114
|
};
|
|
97
115
|
}
|
|
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
116
|
async function handleDbAuditEnable(table, opts) {
|
|
111
117
|
if (!table) {
|
|
112
118
|
throw new error_1.AppError("ARGS_INVALID", "table name is required", {
|
|
113
|
-
next_actions: [
|
|
119
|
+
next_actions: [
|
|
120
|
+
"Usage: miaoda db audit enable <table> [--retention 7d|30d|180d|360d|forever]",
|
|
121
|
+
],
|
|
114
122
|
});
|
|
115
123
|
}
|
|
116
124
|
const retention = opts.retention ?? "7d";
|
|
117
125
|
if (!VALID_RETENTION.has(retention)) {
|
|
118
|
-
throw new error_1.AppError("INVALID_RETENTION", `
|
|
119
|
-
next_actions: ["Allowed values: 7d
|
|
126
|
+
throw new error_1.AppError("INVALID_RETENTION", `Invalid retention '${retention}'`, {
|
|
127
|
+
next_actions: ["Allowed values: 7d, 30d, 180d, 360d, forever."],
|
|
120
128
|
});
|
|
121
129
|
}
|
|
122
130
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
@@ -127,11 +135,18 @@ async function handleDbAuditEnable(table, opts) {
|
|
|
127
135
|
retention,
|
|
128
136
|
dbBranch: opts.env,
|
|
129
137
|
});
|
|
138
|
+
// PRD JSON:{"data": {"table": "...", "enabled": true, "retention": "..."}}
|
|
130
139
|
if ((0, output_1.isJsonMode)()) {
|
|
131
|
-
(0, output_1.emitOk)(
|
|
140
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
141
|
+
table: status.table,
|
|
142
|
+
enabled: status.enabled,
|
|
143
|
+
retention: status.retention ?? retention,
|
|
144
|
+
}));
|
|
132
145
|
return;
|
|
133
146
|
}
|
|
134
|
-
(0,
|
|
147
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
148
|
+
const prefix = tty ? "✓" : "OK";
|
|
149
|
+
(0, output_1.emit)(`${prefix} Audit enabled for table '${status.table}' (retention: ${status.retention ?? retention})`);
|
|
135
150
|
}
|
|
136
151
|
async function handleDbAuditDisable(table, opts) {
|
|
137
152
|
if (!table) {
|
|
@@ -147,10 +162,15 @@ async function handleDbAuditDisable(table, opts) {
|
|
|
147
162
|
dbBranch: opts.env,
|
|
148
163
|
});
|
|
149
164
|
if ((0, output_1.isJsonMode)()) {
|
|
150
|
-
(0, output_1.emitOk)(
|
|
165
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
166
|
+
table: status.table,
|
|
167
|
+
enabled: status.enabled,
|
|
168
|
+
}));
|
|
151
169
|
return;
|
|
152
170
|
}
|
|
153
|
-
(0,
|
|
171
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
172
|
+
const prefix = tty ? "✓" : "OK";
|
|
173
|
+
(0, output_1.emit)(`${prefix} Audit disabled for table '${status.table}'`);
|
|
154
174
|
}
|
|
155
175
|
async function handleDbAuditList(tables, opts) {
|
|
156
176
|
if (tables.length === 0) {
|
|
@@ -161,7 +181,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
161
181
|
});
|
|
162
182
|
}
|
|
163
183
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
164
|
-
const limit = opts.limit ??
|
|
184
|
+
const limit = opts.limit ?? 20;
|
|
165
185
|
// since 优先级:opts.cursor(前一页末条 event_time)> opts.since
|
|
166
186
|
const sinceRaw = opts.cursor ?? opts.since;
|
|
167
187
|
const since = normalizeTime(sinceRaw, "--since/--cursor");
|
|
@@ -176,21 +196,29 @@ async function handleDbAuditList(tables, opts) {
|
|
|
176
196
|
`FROM _dl_audit_log WHERE ${whereParts.join(" AND ")} ORDER BY event_time DESC LIMIT ${String(limit + 1)}`;
|
|
177
197
|
const results = await api.db.execSql({ appId, sql, dbBranch: opts.env });
|
|
178
198
|
const allRows = collectSelectRows(results);
|
|
179
|
-
// 多取一条用于判断 hasMore;最终只展示 limit 行
|
|
180
199
|
const hasMore = allRows.length > limit;
|
|
181
200
|
const visible = hasMore ? allRows.slice(0, limit) : allRows;
|
|
182
201
|
const nextCursor = hasMore && visible.length > 0 ? asString(visible[visible.length - 1].event_time) : null;
|
|
183
|
-
//
|
|
202
|
+
// 多表混合时统计哪些表无记录
|
|
184
203
|
const seen = new Set();
|
|
185
204
|
for (const r of visible)
|
|
186
205
|
seen.add(asString(r.target_table));
|
|
187
206
|
const skipped = tables.filter((t) => !seen.has(t));
|
|
207
|
+
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
188
208
|
if ((0, output_1.isJsonMode)()) {
|
|
189
|
-
|
|
209
|
+
const items = visible.map((r) => ({
|
|
210
|
+
event_id: asString(r.event_id),
|
|
211
|
+
event_time: asString(r.event_time),
|
|
212
|
+
target_table: asString(r.target_table),
|
|
213
|
+
type: asString(r.type),
|
|
214
|
+
operator: asString(r.operator),
|
|
215
|
+
summary: asString(r.summary),
|
|
216
|
+
details: r.details, // 已经是结构化 JSON,透传
|
|
217
|
+
}));
|
|
218
|
+
(0, output_1.emitPaged)(items, nextCursor, hasMore);
|
|
190
219
|
if (skipped.length > 0) {
|
|
191
220
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
192
221
|
}
|
|
193
|
-
// 退出码:任一表有数据 → 0;全空 → 1(PRD 约定)
|
|
194
222
|
if (visible.length === 0)
|
|
195
223
|
process.exitCode = 1;
|
|
196
224
|
return;
|
|
@@ -204,14 +232,23 @@ async function handleDbAuditList(tables, opts) {
|
|
|
204
232
|
return;
|
|
205
233
|
}
|
|
206
234
|
const tty = (0, render_1.isStdoutTty)();
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
asString(it.
|
|
214
|
-
|
|
235
|
+
// PRD:多表渲染时第一列是 target_table,单表时去掉这一列
|
|
236
|
+
const isMultiTable = tables.length > 1;
|
|
237
|
+
const headers = isMultiTable
|
|
238
|
+
? ["target_table", "event_time", "type", "event_id", "operator", "summary"]
|
|
239
|
+
: ["event_time", "type", "event_id", "operator", "summary"];
|
|
240
|
+
const rows = visible.map((it) => {
|
|
241
|
+
const eventTime = (0, render_1.formatTime)(asString(it.event_time), tty);
|
|
242
|
+
const eventId = truncateId(asString(it.event_id));
|
|
243
|
+
const cells = [
|
|
244
|
+
eventTime,
|
|
245
|
+
asString(it.type),
|
|
246
|
+
eventId,
|
|
247
|
+
asString(it.operator, "—"),
|
|
248
|
+
asString(it.summary),
|
|
249
|
+
];
|
|
250
|
+
return isMultiTable ? [asString(it.target_table), ...cells] : cells;
|
|
251
|
+
});
|
|
215
252
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
216
253
|
if (skipped.length > 0) {
|
|
217
254
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
@@ -224,8 +261,7 @@ async function handleDbAuditList(tables, opts) {
|
|
|
224
261
|
function normalizeTime(input, flagName) {
|
|
225
262
|
if (input === undefined || input === "")
|
|
226
263
|
return undefined;
|
|
227
|
-
// ISO 8601
|
|
228
|
-
// 严格 ISO 上重做一次会损精度
|
|
264
|
+
// ISO 8601 直接透传,避免 parseTimeFilterMs 在严格 ISO 上重做一次损精度
|
|
229
265
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
|
|
230
266
|
return new Date(input).toISOString();
|
|
231
267
|
}
|
|
@@ -241,10 +277,40 @@ function collectSelectRows(results) {
|
|
|
241
277
|
}
|
|
242
278
|
return out;
|
|
243
279
|
}
|
|
244
|
-
|
|
245
|
-
* SQL 字符串字面量转义。仅处理单引号;表名 / 时间戳由调用方控制范围(白名单或
|
|
246
|
-
* 严格 ISO 8601 正则),不在此函数内做。
|
|
247
|
-
*/
|
|
280
|
+
// SQL 字面量转义。表名 / 时间戳由调用方控制范围(白名单或严格 ISO 8601 正则)。
|
|
248
281
|
function escapeSqlLiteral(s) {
|
|
249
282
|
return s.replace(/'/g, "''");
|
|
250
283
|
}
|
|
284
|
+
// PRD 输出 enabled 列用 yes/no 而非 true/false
|
|
285
|
+
function boolToYesNo(b) {
|
|
286
|
+
return b ? "yes" : "no";
|
|
287
|
+
}
|
|
288
|
+
// 后端 ValidDay (int64 天数) → PRD retention 字符串。-1 = 永久(前端 / 后端约定)
|
|
289
|
+
function retentionDaysToString(days) {
|
|
290
|
+
if (days === -1)
|
|
291
|
+
return "forever";
|
|
292
|
+
if (days === 7)
|
|
293
|
+
return "7d";
|
|
294
|
+
if (days === 30)
|
|
295
|
+
return "30d";
|
|
296
|
+
if (days === 180)
|
|
297
|
+
return "180d";
|
|
298
|
+
if (days === 360)
|
|
299
|
+
return "360d";
|
|
300
|
+
return `${String(days)}d`;
|
|
301
|
+
}
|
|
302
|
+
function asString(v, fallback = "") {
|
|
303
|
+
if (v == null)
|
|
304
|
+
return fallback;
|
|
305
|
+
if (typeof v === "string")
|
|
306
|
+
return v;
|
|
307
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
|
|
308
|
+
return String(v);
|
|
309
|
+
return JSON.stringify(v);
|
|
310
|
+
}
|
|
311
|
+
// audit list 的 event_id 在 TTY 视图里截断展示(PRD 示例 "01525416B44F...")
|
|
312
|
+
function truncateId(id) {
|
|
313
|
+
if (id.length <= 12)
|
|
314
|
+
return id;
|
|
315
|
+
return id.slice(0, 12) + "...";
|
|
316
|
+
}
|
|
@@ -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)(
|
|
91
|
+
(0, output_1.emitPaged)((0, output_1.snakeCaseKeys)(rows), null, false);
|
|
80
92
|
}
|
|
81
93
|
else {
|
|
82
|
-
(0, output_1.emitPaged)(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
["
|
|
65
|
-
["
|
|
66
|
-
["
|
|
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
|
-
|
|
72
|
+
renderDiff(result);
|
|
73
73
|
}
|
|
74
74
|
async function handleDbMigrationApply(opts) {
|
|
75
75
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
76
|
-
//
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)(
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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,17 @@ 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
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)(data));
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
|
+
// PRD:单行 "Storage: 14.9 MB / 1 GB (1.5%)";配额未对接时只显示 used
|
|
48
49
|
const tty = (0, render_1.isStdoutTty)();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const pct = data.storageQuotaBytes > 0 ? `${data.usagePercent.toFixed(1)}%` : "—";
|
|
50
|
+
const storageLine = data.storageQuotaBytes > 0
|
|
51
|
+
? `${(0, render_1.formatSize)(data.storageUsedBytes)} / ${(0, render_1.formatSize)(data.storageQuotaBytes)} (${data.usagePercent.toFixed(1)}%)`
|
|
52
|
+
: (0, render_1.formatSize)(data.storageUsedBytes);
|
|
53
53
|
(0, output_1.emit)((0, render_1.renderKeyValue)([
|
|
54
|
-
["
|
|
55
|
-
["
|
|
56
|
-
["
|
|
57
|
-
["tables", String(data.tables)],
|
|
58
|
-
["views", String(data.views)],
|
|
54
|
+
["Storage", storageLine],
|
|
55
|
+
["Tables", String(data.tables)],
|
|
56
|
+
["Views", String(data.views)],
|
|
59
57
|
], tty));
|
|
60
58
|
}
|
|
@@ -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
|
-
|
|
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 高危:
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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", `
|
|
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", `
|
|
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
|
-
|
|
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)(
|
|
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)(`
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
c.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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,16 @@ 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
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)(data));
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
|
+
// PRD:单行 "Storage: 150 MB / 1 GB (15%)";配额未对接时只显示 used
|
|
48
49
|
const tty = (0, render_1.isStdoutTty)();
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const storageLine = data.storageQuotaBytes > 0
|
|
51
|
+
? `${(0, render_1.formatSize)(data.storageUsedBytes)} / ${(0, render_1.formatSize)(data.storageQuotaBytes)} (${data.usagePercent.toFixed(1)}%)`
|
|
52
|
+
: (0, render_1.formatSize)(data.storageUsedBytes);
|
|
52
53
|
(0, output_1.emit)((0, render_1.renderKeyValue)([
|
|
53
|
-
["
|
|
54
|
-
["
|
|
55
|
-
["usage", pct],
|
|
56
|
-
["files", String(data.files)],
|
|
54
|
+
["Storage", storageLine],
|
|
55
|
+
["Files", String(data.files)],
|
|
57
56
|
], tty));
|
|
58
57
|
}
|
package/dist/utils/output.js
CHANGED
|
@@ -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();
|