@lark-apaas/miaoda-cli 0.1.2-alpha.4d0ff57 → 0.1.2-alpha.514221e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/db/api.js +73 -4
- package/dist/api/db/client.js +1 -1
- package/dist/api/db/index.js +3 -1
- package/dist/cli/commands/db/index.js +1 -1
- package/dist/cli/handlers/db/audit.js +145 -110
- package/dist/cli/handlers/db/changelog.js +16 -3
- package/dist/cli/handlers/db/migration.js +122 -36
- package/dist/cli/handlers/db/quota.js +18 -10
- package/dist/cli/handlers/db/recovery.js +142 -44
- package/dist/cli/handlers/file/quota.js +16 -8
- package/dist/utils/output.js +22 -0
- package/package.json +1 -1
package/dist/api/db/api.js
CHANGED
|
@@ -5,7 +5,9 @@ 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;
|
|
10
|
+
exports.listAuditLog = listAuditLog;
|
|
9
11
|
exports.migrationInit = migrationInit;
|
|
10
12
|
exports.migrate = migrate;
|
|
11
13
|
exports.recover = recover;
|
|
@@ -332,13 +334,35 @@ async function listDDLChangelog(opts) {
|
|
|
332
334
|
hasMore: Boolean(data.hasMore),
|
|
333
335
|
};
|
|
334
336
|
}
|
|
335
|
-
// ── db audit → InnerAdminSetAuditConfig ──
|
|
337
|
+
// ── db audit → InnerAdminGetAuditStatus / InnerAdminSetAuditConfig ──
|
|
338
|
+
/**
|
|
339
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/audit/status?table=&dbBranch=
|
|
340
|
+
* 查表审计开关状态。table 非空 → 单表过滤;空 → 返当前 workspace 全部已配置表。
|
|
341
|
+
*/
|
|
342
|
+
async function getAuditStatus(opts) {
|
|
343
|
+
const client = (0, http_1.getHttpClient)();
|
|
344
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/audit/status", {
|
|
345
|
+
table: opts.table,
|
|
346
|
+
dbBranch: opts.dbBranch,
|
|
347
|
+
});
|
|
348
|
+
const start = Date.now();
|
|
349
|
+
let response;
|
|
350
|
+
try {
|
|
351
|
+
response = await client.get(url);
|
|
352
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
356
|
+
await mapDbHttpError(err, url, "Failed to get audit status");
|
|
357
|
+
throw err; // 不可达
|
|
358
|
+
}
|
|
359
|
+
const respBody = (await response.json());
|
|
360
|
+
const data = (0, client_1.extractData)(respBody);
|
|
361
|
+
return data.items ?? [];
|
|
362
|
+
}
|
|
336
363
|
/**
|
|
337
364
|
* 后端:POST /v1/dataloom/app/{appId}/db/audit/config
|
|
338
365
|
* 写:enabled=true 开启 / false 关闭。retention 仅 enabled=true 生效。
|
|
339
|
-
*
|
|
340
|
-
* 读路径(status / log)不在此模块——CLI handler 走 execSql 直接 SELECT
|
|
341
|
-
* `_dl_audit_config` / `_dl_audit_log` 元数据表。
|
|
342
366
|
*/
|
|
343
367
|
async function setAuditConfig(opts) {
|
|
344
368
|
const client = (0, http_1.getHttpClient)();
|
|
@@ -369,6 +393,51 @@ async function setAuditConfig(opts) {
|
|
|
369
393
|
}
|
|
370
394
|
return data.status;
|
|
371
395
|
}
|
|
396
|
+
// ── db audit log → InnerAdminListAuditLog ──
|
|
397
|
+
/**
|
|
398
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/audit/log?tables=&since=&until=&limit=&cursor=&dbBranch=
|
|
399
|
+
*
|
|
400
|
+
* 走 admin-inner 接口而不是 InnerAdminExecuteSQL 直接 SELECT pg_audit:
|
|
401
|
+
* - operator 在 details JSONB 内是 user_id,服务端解析成 username
|
|
402
|
+
* - summary 后端按 type + before/after diff 合成(pg_audit 表无此列)
|
|
403
|
+
* - before/after JSONB 后端 JSON.stringify 后透传字符串,CLI 按需 parse
|
|
404
|
+
*
|
|
405
|
+
* 多表用逗号拼接走 query;后端按 target_table IN (...) 一次查。skipped 字段返
|
|
406
|
+
* 多表中无记录的表名,便于 CLI 展示 hint。
|
|
407
|
+
*/
|
|
408
|
+
async function listAuditLog(opts) {
|
|
409
|
+
if (opts.tables.length === 0) {
|
|
410
|
+
throw new error_1.AppError("ARGS_INVALID", "at least one table is required");
|
|
411
|
+
}
|
|
412
|
+
const client = (0, http_1.getHttpClient)();
|
|
413
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/audit/log", {
|
|
414
|
+
tables: opts.tables.join(","),
|
|
415
|
+
since: opts.since,
|
|
416
|
+
until: opts.until,
|
|
417
|
+
limit: opts.limit !== undefined ? String(opts.limit) : undefined,
|
|
418
|
+
cursor: opts.cursor,
|
|
419
|
+
dbBranch: opts.dbBranch,
|
|
420
|
+
});
|
|
421
|
+
const start = Date.now();
|
|
422
|
+
let response;
|
|
423
|
+
try {
|
|
424
|
+
response = await client.get(url);
|
|
425
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
429
|
+
await mapDbHttpError(err, url, "Failed to list audit log");
|
|
430
|
+
throw err; // 不可达
|
|
431
|
+
}
|
|
432
|
+
const body = (await response.json());
|
|
433
|
+
const data = (0, client_1.extractData)(body);
|
|
434
|
+
return {
|
|
435
|
+
items: data.items ?? [],
|
|
436
|
+
nextCursor: data.nextCursor && data.nextCursor !== "" ? data.nextCursor : null,
|
|
437
|
+
hasMore: Boolean(data.hasMore),
|
|
438
|
+
skipped: data.skipped ?? [],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
372
441
|
// ── db migration → InnerAdminMigrationInit / InnerAdminMigrate ──
|
|
373
442
|
/**
|
|
374
443
|
* 后端:POST /v1/dataloom/app/{appId}/db/enableMultiEnv
|
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: {
|
package/dist/api/db/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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.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; } });
|
|
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; } });
|
|
12
|
+
Object.defineProperty(exports, "listAuditLog", { enumerable: true, get: function () { return api_1.listAuditLog; } });
|
|
11
13
|
Object.defineProperty(exports, "migrationInit", { enumerable: true, get: function () { return api_1.migrationInit; } });
|
|
12
14
|
Object.defineProperty(exports, "migrate", { enumerable: true, get: function () { return api_1.migrate; } });
|
|
13
15
|
Object.defineProperty(exports, "recover", { enumerable: true, get: function () { return api_1.recover; } });
|
|
@@ -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
|
});
|
|
@@ -40,98 +40,113 @@ exports.handleDbAuditList = handleDbAuditList;
|
|
|
40
40
|
const api = __importStar(require("../../../api/index"));
|
|
41
41
|
const index_1 = require("../../../api/file/index");
|
|
42
42
|
const shared_1 = require("../../../cli/commands/shared");
|
|
43
|
+
const colors_1 = require("../../../utils/colors");
|
|
43
44
|
const error_1 = require("../../../utils/error");
|
|
44
45
|
const output_1 = require("../../../utils/output");
|
|
45
46
|
const render_1 = require("../../../utils/render");
|
|
46
|
-
const
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
+
// PRD JSON:单表返 object,多表返 array
|
|
61
57
|
if ((0, output_1.isJsonMode)()) {
|
|
62
|
-
(
|
|
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 (
|
|
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
|
-
//
|
|
71
|
-
if (table !== undefined &&
|
|
72
|
-
const it =
|
|
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
|
-
["
|
|
75
|
-
["
|
|
76
|
-
["
|
|
77
|
-
["
|
|
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 =
|
|
84
|
+
const out = rows.map((it) => [
|
|
83
85
|
it.table,
|
|
84
|
-
|
|
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(
|
|
92
|
+
function toAuditStatus(s) {
|
|
91
93
|
return {
|
|
92
|
-
table:
|
|
93
|
-
enabled:
|
|
94
|
-
enabled_at:
|
|
95
|
-
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: [
|
|
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", `
|
|
119
|
-
next_actions: ["Allowed values: 7d
|
|
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);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
|
136
|
+
// PRD JSON:{"data": {"table": "...", "enabled": true, "retention": "..."}}
|
|
130
137
|
if ((0, output_1.isJsonMode)()) {
|
|
131
|
-
(0, output_1.emitOk)(
|
|
138
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
139
|
+
table: status.table,
|
|
140
|
+
enabled: status.enabled,
|
|
141
|
+
retention: status.retention ?? retention,
|
|
142
|
+
}));
|
|
132
143
|
return;
|
|
133
144
|
}
|
|
134
|
-
(0,
|
|
145
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
146
|
+
const prefix = tty ? "✓" : "OK";
|
|
147
|
+
// c.highlight 内部按 TTY/NO_COLOR/FORCE_COLOR 自动决定是否染色,无需再判 tty
|
|
148
|
+
const tableLabel = colors_1.c.highlight(`'${status.table}'`);
|
|
149
|
+
(0, output_1.emit)(`${prefix} Audit enabled for table ${tableLabel} (retention: ${status.retention ?? retention})`);
|
|
135
150
|
}
|
|
136
151
|
async function handleDbAuditDisable(table, opts) {
|
|
137
152
|
if (!table) {
|
|
@@ -140,17 +155,34 @@ async function handleDbAuditDisable(table, opts) {
|
|
|
140
155
|
});
|
|
141
156
|
}
|
|
142
157
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
176
|
if ((0, output_1.isJsonMode)()) {
|
|
150
|
-
(0, output_1.emitOk)(
|
|
177
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
178
|
+
table: status.table,
|
|
179
|
+
enabled: status.enabled,
|
|
180
|
+
}));
|
|
151
181
|
return;
|
|
152
182
|
}
|
|
153
|
-
(0,
|
|
183
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
184
|
+
const prefix = tty ? "✓" : "OK";
|
|
185
|
+
(0, output_1.emit)(`${prefix} Audit disabled for table ${colors_1.c.highlight(`'${status.table}'`)}`);
|
|
154
186
|
}
|
|
155
187
|
async function handleDbAuditList(tables, opts) {
|
|
156
188
|
if (tables.length === 0) {
|
|
@@ -161,36 +193,39 @@ async function handleDbAuditList(tables, opts) {
|
|
|
161
193
|
});
|
|
162
194
|
}
|
|
163
195
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
164
|
-
const limit = opts.limit ??
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
const since = normalizeTime(
|
|
196
|
+
const limit = opts.limit ?? 20;
|
|
197
|
+
// 时间字段归一化为 ISO 8601 UTC(服务端按 event_time 字段比较)。cursor 由
|
|
198
|
+
// 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
|
|
199
|
+
const since = normalizeTime(opts.since, "--since");
|
|
168
200
|
const until = normalizeTime(opts.until, "--until");
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const visible = hasMore ? allRows.slice(0, limit) : allRows;
|
|
182
|
-
const nextCursor = hasMore && visible.length > 0 ? asString(visible[visible.length - 1].event_time) : null;
|
|
183
|
-
// 多表混合时统计哪些表无记录,PRD 要求末尾汇总
|
|
184
|
-
const seen = new Set();
|
|
185
|
-
for (const r of visible)
|
|
186
|
-
seen.add(asString(r.target_table));
|
|
187
|
-
const skipped = tables.filter((t) => !seen.has(t));
|
|
201
|
+
const result = await api.db.listAuditLog({
|
|
202
|
+
appId,
|
|
203
|
+
tables,
|
|
204
|
+
since,
|
|
205
|
+
until,
|
|
206
|
+
limit,
|
|
207
|
+
cursor: opts.cursor,
|
|
208
|
+
dbBranch: opts.env,
|
|
209
|
+
});
|
|
210
|
+
const visible = result.items;
|
|
211
|
+
const skipped = result.skipped;
|
|
212
|
+
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
188
213
|
if ((0, output_1.isJsonMode)()) {
|
|
189
|
-
|
|
214
|
+
const items = visible.map((it) => ({
|
|
215
|
+
event_id: it.eventId,
|
|
216
|
+
event_time: it.eventTime,
|
|
217
|
+
target_table: it.targetTable,
|
|
218
|
+
type: it.type,
|
|
219
|
+
operator: it.operator,
|
|
220
|
+
summary: it.summary,
|
|
221
|
+
// before/after 服务端是 JSON 字符串,反序列化回结构化对象供下游消费
|
|
222
|
+
...(it.before !== undefined ? { before: safeParseJson(it.before) } : {}),
|
|
223
|
+
...(it.after !== undefined ? { after: safeParseJson(it.after) } : {}),
|
|
224
|
+
}));
|
|
225
|
+
(0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
|
|
190
226
|
if (skipped.length > 0) {
|
|
191
227
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
192
228
|
}
|
|
193
|
-
// 退出码:任一表有数据 → 0;全空 → 1(PRD 约定)
|
|
194
229
|
if (visible.length === 0)
|
|
195
230
|
process.exitCode = 1;
|
|
196
231
|
return;
|
|
@@ -204,47 +239,47 @@ async function handleDbAuditList(tables, opts) {
|
|
|
204
239
|
return;
|
|
205
240
|
}
|
|
206
241
|
const tty = (0, render_1.isStdoutTty)();
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
// PRD:多表渲染时第一列是 target_table,单表时去掉这一列
|
|
243
|
+
const isMultiTable = tables.length > 1;
|
|
244
|
+
const headers = isMultiTable
|
|
245
|
+
? ["target_table", "event_time", "type", "event_id", "operator", "summary"]
|
|
246
|
+
: ["event_time", "type", "event_id", "operator", "summary"];
|
|
247
|
+
const rows = visible.map((it) => {
|
|
248
|
+
const eventTime = (0, render_1.formatTime)(it.eventTime, tty);
|
|
249
|
+
// event_id 完整透传——PRD 截图里的 "..." 只是文档省略写法,不是 CLI 行为
|
|
250
|
+
const cells = [eventTime, it.type, it.eventId, it.operator || "—", it.summary];
|
|
251
|
+
return isMultiTable ? [it.targetTable, ...cells] : cells;
|
|
252
|
+
});
|
|
215
253
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
216
254
|
if (skipped.length > 0) {
|
|
217
255
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
218
256
|
}
|
|
219
|
-
if (hasMore && nextCursor) {
|
|
220
|
-
process.stderr.write(`(more results; use --cursor '${nextCursor}')\n`);
|
|
257
|
+
if (result.hasMore && result.nextCursor) {
|
|
258
|
+
process.stderr.write(`(more results; use --cursor '${result.nextCursor}')\n`);
|
|
221
259
|
}
|
|
222
260
|
}
|
|
223
261
|
// ── helpers ──
|
|
224
262
|
function normalizeTime(input, flagName) {
|
|
225
263
|
if (input === undefined || input === "")
|
|
226
264
|
return undefined;
|
|
227
|
-
// ISO 8601
|
|
228
|
-
// 严格 ISO 上重做一次会损精度
|
|
265
|
+
// ISO 8601 直接透传,避免 parseTimeFilterMs 在严格 ISO 上重做一次损精度
|
|
229
266
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
|
|
230
267
|
return new Date(input).toISOString();
|
|
231
268
|
}
|
|
232
269
|
const ms = (0, index_1.parseTimeFilterMs)(input, flagName);
|
|
233
270
|
return new Date(ms).toISOString();
|
|
234
271
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
272
|
+
// 服务端 before/after 是 JSON 字符串透传,CLI JSON 输出再反序列化回对象,
|
|
273
|
+
// 给下游 jq 等工具消费。失败时透传原始字符串避免数据丢失。
|
|
274
|
+
function safeParseJson(s) {
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(s);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return s;
|
|
280
|
+
}
|
|
243
281
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
*/
|
|
248
|
-
function escapeSqlLiteral(s) {
|
|
249
|
-
return s.replace(/'/g, "''");
|
|
282
|
+
// PRD 输出 enabled 列用 yes/no 而非 true/false
|
|
283
|
+
function boolToYesNo(b) {
|
|
284
|
+
return b ? "yes" : "no";
|
|
250
285
|
}
|
|
@@ -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),
|
|
@@ -42,62 +42,133 @@ 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");
|
|
46
47
|
const render_1 = require("../../../utils/render");
|
|
47
48
|
async function handleDbMigrationInit(opts) {
|
|
48
49
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
49
|
-
//
|
|
50
|
+
// 不可逆操作,TTY 默认要求 y/N;--yes 跳过
|
|
50
51
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
51
|
-
|
|
52
|
+
// 文案对齐 PRD:先讲不可逆,再问是否初始化;syncData 时追加一句而不是塞进同一句话
|
|
53
|
+
const suffix = opts.syncData ? " (existing data will be copied to dev)" : "";
|
|
54
|
+
const ok = await confirm(`? This action is irreversible. Initialize multi-env (dev / online)${suffix}? (y/N) `);
|
|
52
55
|
if (!ok) {
|
|
53
56
|
(0, output_1.emit)("Aborted.");
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
|
-
|
|
60
|
+
let result;
|
|
61
|
+
try {
|
|
62
|
+
result = await api.db.migrationInit({ appId, syncData: opts.syncData });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// PRD: 重复 init 报错带 hint,引导用户去 diff 看待发布变更
|
|
66
|
+
if (err instanceof error_1.AppError && err.code === "DB_API_k_dl_1300034") {
|
|
67
|
+
throw new error_1.AppError(err.code, err.message, {
|
|
68
|
+
next_actions: ["Run `miaoda db migration diff` to view pending changes."],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
58
73
|
if ((0, output_1.isJsonMode)()) {
|
|
59
|
-
(0, output_1.emitOk)(result);
|
|
74
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)(result));
|
|
60
75
|
return;
|
|
61
76
|
}
|
|
77
|
+
// PRD 单行格式(不用 key:value 表格):
|
|
78
|
+
// 默认 ✓ / OK: "Multi-env initialized (dev / online)"
|
|
79
|
+
// --sync-data: "Multi-env initialized, data synced to dev"
|
|
62
80
|
const tty = (0, render_1.isStdoutTty)();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const prefix = tty ? "✓" : "OK";
|
|
82
|
+
const body = result.dataSynced
|
|
83
|
+
? "Multi-env initialized, data synced to dev"
|
|
84
|
+
: `Multi-env initialized (${result.environments.join(" / ")})`;
|
|
85
|
+
(0, output_1.emit)(`${prefix} ${body}`);
|
|
68
86
|
}
|
|
69
87
|
async function handleDbMigrationDiff(opts) {
|
|
70
88
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = await api.db.migrate({ appId, dryRun: true });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw decorateMigrationError(err);
|
|
95
|
+
}
|
|
96
|
+
// PRD: diff 在无待发布变更时报错带 hint,而不是渲染空列表
|
|
97
|
+
if (result.changes.length === 0) {
|
|
98
|
+
throw new error_1.AppError("DB_API_k_dl_1300035", `No pending changes between ${result.from} and ${result.to}`, {
|
|
99
|
+
next_actions: [
|
|
100
|
+
'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
renderDiff(result);
|
|
73
105
|
}
|
|
74
106
|
async function handleDbMigrationApply(opts) {
|
|
75
107
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
// TTY 下先 diff 给用户审;--yes 直接打到 online
|
|
109
|
+
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
110
|
+
let preview;
|
|
111
|
+
try {
|
|
112
|
+
preview = await api.db.migrate({ appId, dryRun: true });
|
|
81
113
|
}
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw decorateMigrationError(err);
|
|
84
116
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
if (preview.changes.length === 0) {
|
|
118
|
+
// PRD 文案 + hint
|
|
119
|
+
throw new error_1.AppError("DB_API_k_dl_1300035", `No pending changes between ${preview.from} and ${preview.to}`, {
|
|
120
|
+
next_actions: [
|
|
121
|
+
'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
renderDiff(preview);
|
|
126
|
+
const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
|
|
90
127
|
if (!ok) {
|
|
91
128
|
(0, output_1.emit)("Aborted.");
|
|
92
129
|
return;
|
|
93
130
|
}
|
|
94
131
|
}
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
let result;
|
|
133
|
+
try {
|
|
134
|
+
result = await api.db.migrate({ appId, dryRun: false });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
throw decorateMigrationError(err);
|
|
138
|
+
}
|
|
139
|
+
if ((0, output_1.isJsonMode)()) {
|
|
140
|
+
// PRD:{"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}
|
|
141
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
142
|
+
status: result.status ?? "applied",
|
|
143
|
+
from: result.from,
|
|
144
|
+
to: result.to,
|
|
145
|
+
changesApplied: result.changesApplied ?? result.changes.length,
|
|
146
|
+
}));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
150
|
+
const prefix = tty ? "✓" : "OK";
|
|
151
|
+
const arrow = tty ? "→" : "->";
|
|
152
|
+
const applied = result.changesApplied ?? result.changes.length;
|
|
153
|
+
(0, output_1.emit)(`${prefix} Applied ${result.from} ${arrow} ${result.to} (${String(applied)} changes)`);
|
|
97
154
|
}
|
|
98
|
-
|
|
155
|
+
// ── helpers ──
|
|
156
|
+
// PRD diff 输出:
|
|
157
|
+
// dev → online (2 changes):
|
|
158
|
+
//
|
|
159
|
+
// ALTER TABLE users ADD COLUMN avatar_url text;
|
|
160
|
+
// CREATE INDEX idx_users_avatar ON users(avatar_url);
|
|
161
|
+
function renderDiff(result) {
|
|
99
162
|
if ((0, output_1.isJsonMode)()) {
|
|
100
|
-
(0, output_1.emitOk)(
|
|
163
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
164
|
+
from: result.from,
|
|
165
|
+
to: result.to,
|
|
166
|
+
changes: result.changes.map((c) => ({
|
|
167
|
+
type: c.type,
|
|
168
|
+
table: c.table,
|
|
169
|
+
statement: c.statement,
|
|
170
|
+
})),
|
|
171
|
+
}));
|
|
101
172
|
return;
|
|
102
173
|
}
|
|
103
174
|
const tty = (0, render_1.isStdoutTty)();
|
|
@@ -105,17 +176,32 @@ function renderMigrate(result) {
|
|
|
105
176
|
(0, output_1.emit)(`No pending changes from ${result.from} to ${result.to}.`);
|
|
106
177
|
return;
|
|
107
178
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
179
|
+
const arrow = tty ? "→" : "->";
|
|
180
|
+
(0, output_1.emit)(`${result.from} ${arrow} ${result.to} (${String(result.changes.length)} changes):\n\n` +
|
|
181
|
+
result.changes.map((c) => ` ${c.statement}`).join("\n"));
|
|
182
|
+
}
|
|
183
|
+
// decorateMigrationError 给 migration / recovery 路径上的几个错误码补 PRD 规定的 hint。
|
|
184
|
+
// dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
|
|
185
|
+
function decorateMigrationError(err) {
|
|
186
|
+
if (!(err instanceof error_1.AppError))
|
|
187
|
+
return err;
|
|
188
|
+
switch (err.code) {
|
|
189
|
+
case "DB_API_k_dl_1300039":
|
|
190
|
+
// 多环境未初始化:引导先 init
|
|
191
|
+
return new error_1.AppError(err.code, err.message, {
|
|
192
|
+
next_actions: ["Run `miaoda db migration init` to set up multi-env first."],
|
|
193
|
+
});
|
|
194
|
+
case "DB_API_k_dl_1300035":
|
|
195
|
+
// 无待发布变更:引导先在 dev 改 schema
|
|
196
|
+
return new error_1.AppError(err.code, err.message, {
|
|
197
|
+
next_actions: [
|
|
198
|
+
'Make schema changes in dev first (e.g. `miaoda db sql "ALTER TABLE ..." --env dev`)',
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
default:
|
|
202
|
+
return err;
|
|
116
203
|
}
|
|
117
204
|
}
|
|
118
|
-
// ── confirm ──
|
|
119
205
|
async function confirm(prompt) {
|
|
120
206
|
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
121
207
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
["
|
|
55
|
-
["
|
|
56
|
-
["
|
|
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
|
}
|
|
@@ -48,86 +48,184 @@ const render_1 = require("../../../utils/render");
|
|
|
48
48
|
async function handleDbRecoveryDiff(target, opts) {
|
|
49
49
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
50
50
|
const ts = normalizeTimestamp(target);
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
throw decorateRecoveryError(err);
|
|
57
|
+
}
|
|
58
|
+
renderDiff(result);
|
|
53
59
|
}
|
|
54
60
|
async function handleDbRecoveryApply(target, opts) {
|
|
55
61
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
56
62
|
const ts = normalizeTimestamp(target);
|
|
57
|
-
// PITR 高危:
|
|
58
|
-
const preview = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
63
|
+
// PITR 高危:TTY 下先 diff 给用户审;--yes 直接执行
|
|
59
64
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
let preview;
|
|
66
|
+
try {
|
|
67
|
+
preview = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
throw decorateRecoveryError(err);
|
|
71
|
+
}
|
|
72
|
+
renderDiff(preview);
|
|
73
|
+
const ok = await confirm(`? Restore database to ${preview.target}? This will overwrite current data. (y/N) `);
|
|
62
74
|
if (!ok) {
|
|
63
75
|
(0, output_1.emit)("Aborted.");
|
|
64
76
|
return;
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await api.db.recover({ appId, target: ts, dryRun: false });
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw decorateRecoveryError(err);
|
|
85
|
+
}
|
|
86
|
+
if ((0, output_1.isJsonMode)()) {
|
|
87
|
+
// PRD:{"status": "restored", "target": "...", "tables_affected": 2, "elapsed_seconds": 30}
|
|
88
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
89
|
+
status: result.status ?? "restored",
|
|
90
|
+
target: result.target,
|
|
91
|
+
tablesAffected: result.tablesAffected,
|
|
92
|
+
elapsedSeconds: result.elapsedSeconds ?? 0,
|
|
93
|
+
}));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
97
|
+
const prefix = tty ? "✓" : "OK";
|
|
98
|
+
(0, output_1.emit)(`${prefix} Database restored to ${result.target} ` +
|
|
99
|
+
`(${String(result.tablesAffected)} tables affected, ${String(result.elapsedSeconds ?? 0)}s elapsed)`);
|
|
69
100
|
}
|
|
70
101
|
// ── helpers ──
|
|
71
102
|
/**
|
|
72
|
-
* 把用户传入的时间统一成 ISO 8601 UTC。
|
|
73
|
-
*
|
|
103
|
+
* 把用户传入的时间统一成 ISO 8601 UTC。PRD 只接受两种格式:
|
|
104
|
+
* - `YYYY-MM-DD`(按 UTC 0 点解释)
|
|
105
|
+
* - 完整 ISO 8601 `YYYY-MM-DDTHH:MM:SS[Z|±HH:MM]`
|
|
106
|
+
*
|
|
107
|
+
* 严格 regex 匹配 + 不用 new Date(input) 兜底——Node Date 解析 `2026/04/15` 这种
|
|
108
|
+
* 非 ISO 字符串居然成功(落到本地时区),绕过格式校验把脏值送到后端,导致用户拿到
|
|
109
|
+
* 「窗口超限」错误而不是「格式错误」。PRD 明确这两种格式以外的都应该报 INVALID_TIMESTAMP。
|
|
74
110
|
*/
|
|
75
111
|
function normalizeTimestamp(input) {
|
|
76
|
-
|
|
112
|
+
// PRD hint 文案对齐 PRD 截图(同时是 dataloom 端 ErrInvalidTimestamp 的 hint)
|
|
113
|
+
const FORMAT_HINT = "Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z";
|
|
77
114
|
if (input === "") {
|
|
78
115
|
throw new error_1.AppError("ARGS_INVALID", "target timestamp is required", {
|
|
79
116
|
next_actions: ["Usage: miaoda db recovery diff|apply <timestamp>"],
|
|
80
117
|
});
|
|
81
118
|
}
|
|
82
|
-
// YYYY-MM-DD
|
|
119
|
+
// 仅日期:YYYY-MM-DD → 按 UTC 0 点
|
|
83
120
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
// YYYY-MM-DD HH:MM[:SS]:本地时间,让 Date 自己解析
|
|
87
|
-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(input)) {
|
|
88
|
-
const d = new Date(input.replace(" ", "T"));
|
|
121
|
+
const d = new Date(`${input}T00:00:00Z`);
|
|
89
122
|
if (Number.isNaN(d.getTime())) {
|
|
90
|
-
throw new error_1.AppError("INVALID_TIMESTAMP", `
|
|
123
|
+
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
91
124
|
next_actions: [FORMAT_HINT],
|
|
92
125
|
});
|
|
93
126
|
}
|
|
94
127
|
return d.toISOString();
|
|
95
128
|
}
|
|
96
|
-
// 完整 ISO 8601
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
129
|
+
// 完整 ISO 8601 datetime:T 分隔,秒可选 ms,Z 或 ±HH:MM 时区
|
|
130
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
|
|
131
|
+
const d = new Date(input);
|
|
132
|
+
if (Number.isNaN(d.getTime())) {
|
|
133
|
+
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
134
|
+
next_actions: [FORMAT_HINT],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return d.toISOString();
|
|
102
138
|
}
|
|
103
|
-
|
|
139
|
+
// 其它一律按格式错误拒绝(`2026/04/15` / `2026.04.15` / `2026年4月15日` 等都走这里)
|
|
140
|
+
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
141
|
+
next_actions: [FORMAT_HINT],
|
|
142
|
+
});
|
|
104
143
|
}
|
|
105
|
-
|
|
144
|
+
// PRD diff 输出(结构化 prose):
|
|
145
|
+
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
146
|
+
//
|
|
147
|
+
// tables affected: 2
|
|
148
|
+
// users: +3 rows, -1 row, ~5 rows modified
|
|
149
|
+
// orders: table will be restored (was dropped at 10:25:00)
|
|
150
|
+
//
|
|
151
|
+
// estimated time: ~30s
|
|
152
|
+
function renderDiff(result) {
|
|
106
153
|
if ((0, output_1.isJsonMode)()) {
|
|
107
|
-
(0, output_1.emitOk)(
|
|
154
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
155
|
+
target: result.target,
|
|
156
|
+
tablesAffected: result.tablesAffected,
|
|
157
|
+
changes: result.changes.map((c) => ({
|
|
158
|
+
table: c.table,
|
|
159
|
+
inserted: c.inserted,
|
|
160
|
+
deleted: c.deleted,
|
|
161
|
+
modified: c.modified,
|
|
162
|
+
action: c.action,
|
|
163
|
+
droppedAt: c.droppedAt,
|
|
164
|
+
})),
|
|
165
|
+
estimatedSeconds: result.estimatedSeconds ?? 0,
|
|
166
|
+
}));
|
|
108
167
|
return;
|
|
109
168
|
}
|
|
110
169
|
const tty = (0, render_1.isStdoutTty)();
|
|
170
|
+
const arrow = tty ? "→" : "->";
|
|
111
171
|
if (result.changes.length === 0) {
|
|
112
|
-
(0, output_1.emit)(`
|
|
172
|
+
(0, output_1.emit)(`Recovery preview (${arrow} ${result.target}):\n\n` +
|
|
173
|
+
` No changes — database is already at this state.`);
|
|
113
174
|
return;
|
|
114
175
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
c.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
176
|
+
const lines = [
|
|
177
|
+
`Recovery preview (${arrow} ${result.target}):`,
|
|
178
|
+
"",
|
|
179
|
+
` tables affected: ${String(result.tablesAffected)}`,
|
|
180
|
+
];
|
|
181
|
+
for (const c of result.changes) {
|
|
182
|
+
lines.push(` ${c.table}: ${describeChange(c)}`);
|
|
183
|
+
}
|
|
184
|
+
if (result.estimatedSeconds !== undefined) {
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push(` estimated time: ~${String(result.estimatedSeconds)}s`);
|
|
187
|
+
}
|
|
188
|
+
(0, output_1.emit)(lines.join("\n"));
|
|
189
|
+
}
|
|
190
|
+
function describeChange(c) {
|
|
191
|
+
if (c.action === "restore_table" || c.action === "drop" || c.action === "create") {
|
|
192
|
+
const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
|
|
193
|
+
if (c.action === "restore_table")
|
|
194
|
+
return `table will be restored${ts}`;
|
|
195
|
+
if (c.action === "drop")
|
|
196
|
+
return `table will be dropped${ts}`;
|
|
197
|
+
return `table will be created${ts}`;
|
|
198
|
+
}
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (c.inserted !== undefined && c.inserted !== 0)
|
|
201
|
+
parts.push(`+${String(c.inserted)} rows`);
|
|
202
|
+
if (c.deleted !== undefined && c.deleted !== 0) {
|
|
203
|
+
parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? "row" : "rows"}`);
|
|
204
|
+
}
|
|
205
|
+
if (c.modified !== undefined && c.modified !== 0)
|
|
206
|
+
parts.push(`~${String(c.modified)} rows modified`);
|
|
207
|
+
return parts.length === 0 ? "no changes" : parts.join(", ");
|
|
208
|
+
}
|
|
209
|
+
// decorateRecoveryError 给 recovery 路径上的几个错误码补 PRD 规定的 hint。
|
|
210
|
+
// dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
|
|
211
|
+
function decorateRecoveryError(err) {
|
|
212
|
+
if (!(err instanceof error_1.AppError))
|
|
213
|
+
return err;
|
|
214
|
+
switch (err.code) {
|
|
215
|
+
case "DB_API_k_dl_1300036":
|
|
216
|
+
// 窗口超限:引导用户检查 last migration apply 时间
|
|
217
|
+
return new error_1.AppError(err.code, err.message, {
|
|
218
|
+
next_actions: [
|
|
219
|
+
"PITR window is up to 7 days back, limited by your last `db migration apply` time.",
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
case "DB_API_k_dl_1300038":
|
|
223
|
+
// 时间格式错误:引导 ISO 8601
|
|
224
|
+
return new error_1.AppError(err.code, err.message, {
|
|
225
|
+
next_actions: ["Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z"],
|
|
226
|
+
});
|
|
227
|
+
default:
|
|
228
|
+
return err;
|
|
131
229
|
}
|
|
132
230
|
}
|
|
133
231
|
async function confirm(prompt) {
|
|
@@ -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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
["
|
|
54
|
-
["
|
|
55
|
-
["usage", pct],
|
|
56
|
-
["files", String(data.files)],
|
|
63
|
+
["Storage", storageLine],
|
|
64
|
+
["Files", String(data.files)],
|
|
57
65
|
], tty));
|
|
58
66
|
}
|
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();
|