@lark-apaas/miaoda-cli 0.1.2-alpha.8bc93eb → 0.1.2-alpha.8f744c0
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 +62 -0
- package/dist/api/db/index.js +2 -1
- package/dist/cli/handlers/db/audit.js +78 -82
- package/dist/cli/handlers/db/migration.js +82 -14
- package/dist/cli/handlers/db/recovery.js +67 -16
- package/dist/utils/http.js +5 -1
- package/package.json +1 -1
package/dist/api/db/api.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.exportData = exportData;
|
|
|
7
7
|
exports.listDDLChangelog = listDDLChangelog;
|
|
8
8
|
exports.getAuditStatus = getAuditStatus;
|
|
9
9
|
exports.setAuditConfig = setAuditConfig;
|
|
10
|
+
exports.listAuditLog = listAuditLog;
|
|
10
11
|
exports.migrationInit = migrationInit;
|
|
11
12
|
exports.migrate = migrate;
|
|
12
13
|
exports.recover = recover;
|
|
@@ -32,6 +33,22 @@ async function mapDbHttpError(err, url, ctx,
|
|
|
32
33
|
onErrorBody) {
|
|
33
34
|
if (err instanceof error_1.AppError)
|
|
34
35
|
throw err;
|
|
36
|
+
// 客户端超时 / abort:SdkHttpError 时 response 通常缺失(status=0),落到 throw 时
|
|
37
|
+
// message 形如 'Failed to recover: 0' 让用户看不懂。识别 abort / timeout 关键字
|
|
38
|
+
// 转成专用错误码 + 友好 hint。
|
|
39
|
+
if (err instanceof Error) {
|
|
40
|
+
const msg = err.message.toLowerCase();
|
|
41
|
+
if (msg.includes("aborted") ||
|
|
42
|
+
msg.includes("timeout") ||
|
|
43
|
+
err.name === "AbortError" ||
|
|
44
|
+
err.name === "TimeoutError") {
|
|
45
|
+
throw new error_1.AppError("REQUEST_TIMEOUT", `${ctx}: request timed out`, {
|
|
46
|
+
next_actions: [
|
|
47
|
+
"Server-side async tasks may take up to 60s. Retry the command if the underlying task likely succeeded.",
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
35
52
|
if (err instanceof http_client_1.HttpError) {
|
|
36
53
|
const status = err.response?.status ?? 0;
|
|
37
54
|
const statusText = err.response?.statusText ?? "";
|
|
@@ -392,6 +409,51 @@ async function setAuditConfig(opts) {
|
|
|
392
409
|
}
|
|
393
410
|
return data.status;
|
|
394
411
|
}
|
|
412
|
+
// ── db audit log → InnerAdminListAuditLog ──
|
|
413
|
+
/**
|
|
414
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/audit/log?tables=&since=&until=&limit=&cursor=&dbBranch=
|
|
415
|
+
*
|
|
416
|
+
* 走 admin-inner 接口而不是 InnerAdminExecuteSQL 直接 SELECT pg_audit:
|
|
417
|
+
* - operator 在 details JSONB 内是 user_id,服务端解析成 username
|
|
418
|
+
* - summary 后端按 type + before/after diff 合成(pg_audit 表无此列)
|
|
419
|
+
* - before/after JSONB 后端 JSON.stringify 后透传字符串,CLI 按需 parse
|
|
420
|
+
*
|
|
421
|
+
* 多表用逗号拼接走 query;后端按 target_table IN (...) 一次查。skipped 字段返
|
|
422
|
+
* 多表中无记录的表名,便于 CLI 展示 hint。
|
|
423
|
+
*/
|
|
424
|
+
async function listAuditLog(opts) {
|
|
425
|
+
if (opts.tables.length === 0) {
|
|
426
|
+
throw new error_1.AppError("ARGS_INVALID", "at least one table is required");
|
|
427
|
+
}
|
|
428
|
+
const client = (0, http_1.getHttpClient)();
|
|
429
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/audit/log", {
|
|
430
|
+
tables: opts.tables.join(","),
|
|
431
|
+
since: opts.since,
|
|
432
|
+
until: opts.until,
|
|
433
|
+
limit: opts.limit !== undefined ? String(opts.limit) : undefined,
|
|
434
|
+
cursor: opts.cursor,
|
|
435
|
+
dbBranch: opts.dbBranch,
|
|
436
|
+
});
|
|
437
|
+
const start = Date.now();
|
|
438
|
+
let response;
|
|
439
|
+
try {
|
|
440
|
+
response = await client.get(url);
|
|
441
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
445
|
+
await mapDbHttpError(err, url, "Failed to list audit log");
|
|
446
|
+
throw err; // 不可达
|
|
447
|
+
}
|
|
448
|
+
const body = (await response.json());
|
|
449
|
+
const data = (0, client_1.extractData)(body);
|
|
450
|
+
return {
|
|
451
|
+
items: data.items ?? [],
|
|
452
|
+
nextCursor: data.nextCursor && data.nextCursor !== "" ? data.nextCursor : null,
|
|
453
|
+
hasMore: Boolean(data.hasMore),
|
|
454
|
+
skipped: data.skipped ?? [],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
395
457
|
// ── db migration → InnerAdminMigrationInit / InnerAdminMigrate ──
|
|
396
458
|
/**
|
|
397
459
|
* 后端:POST /v1/dataloom/app/{appId}/db/enableMultiEnv
|
package/dist/api/db/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pickTableDetail = exports.flattenSchemaList = exports.parseSqlResult = exports.SQLSTATE_MAP = exports.ensureInnerSuccess = exports.buildInnerUrl = exports.getDbQuota = exports.recover = exports.migrate = exports.migrationInit = exports.setAuditConfig = exports.getAuditStatus = exports.listDDLChangelog = exports.exportData = exports.importData = exports.getSchema = exports.execSql = void 0;
|
|
3
|
+
exports.pickTableDetail = exports.flattenSchemaList = exports.parseSqlResult = exports.SQLSTATE_MAP = exports.ensureInnerSuccess = exports.buildInnerUrl = exports.getDbQuota = exports.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; } });
|
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "exportData", { enumerable: true, get: function (
|
|
|
9
9
|
Object.defineProperty(exports, "listDDLChangelog", { enumerable: true, get: function () { return api_1.listDDLChangelog; } });
|
|
10
10
|
Object.defineProperty(exports, "getAuditStatus", { enumerable: true, get: function () { return api_1.getAuditStatus; } });
|
|
11
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; } });
|
|
12
13
|
Object.defineProperty(exports, "migrationInit", { enumerable: true, get: function () { return api_1.migrationInit; } });
|
|
13
14
|
Object.defineProperty(exports, "migrate", { enumerable: true, get: function () { return api_1.migrate; } });
|
|
14
15
|
Object.defineProperty(exports, "recover", { enumerable: true, get: function () { return api_1.recover; } });
|
|
@@ -44,7 +44,6 @@ const colors_1 = require("../../../utils/colors");
|
|
|
44
44
|
const error_1 = require("../../../utils/error");
|
|
45
45
|
const output_1 = require("../../../utils/output");
|
|
46
46
|
const render_1 = require("../../../utils/render");
|
|
47
|
-
const index_2 = require("../../../api/db/index");
|
|
48
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);
|
|
@@ -113,13 +112,27 @@ async function handleDbAuditEnable(table, opts) {
|
|
|
113
112
|
});
|
|
114
113
|
}
|
|
115
114
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
136
|
// PRD JSON:{"data": {"table": "...", "enabled": true, "retention": "..."}}
|
|
124
137
|
if ((0, output_1.isJsonMode)()) {
|
|
125
138
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -142,12 +155,24 @@ async function handleDbAuditDisable(table, opts) {
|
|
|
142
155
|
});
|
|
143
156
|
}
|
|
144
157
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
176
|
if ((0, output_1.isJsonMode)()) {
|
|
152
177
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
153
178
|
table: status.table,
|
|
@@ -169,40 +194,35 @@ async function handleDbAuditList(tables, opts) {
|
|
|
169
194
|
}
|
|
170
195
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
171
196
|
const limit = opts.limit ?? 20;
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
const since = normalizeTime(
|
|
197
|
+
// 时间字段归一化为 ISO 8601 UTC(服务端按 event_time 字段比较)。cursor 由
|
|
198
|
+
// 后端管理(本质也是 ISO 时间),CLI 不再混用 cursor/since。
|
|
199
|
+
const since = normalizeTime(opts.since, "--since");
|
|
175
200
|
const until = normalizeTime(opts.until, "--until");
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const visible = hasMore ? allRows.slice(0, limit) : allRows;
|
|
188
|
-
const nextCursor = hasMore && visible.length > 0 ? asString(visible[visible.length - 1].event_time) : null;
|
|
189
|
-
// 多表混合时统计哪些表无记录
|
|
190
|
-
const seen = new Set();
|
|
191
|
-
for (const r of visible)
|
|
192
|
-
seen.add(asString(r.target_table));
|
|
193
|
-
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;
|
|
194
212
|
// PRD JSON:data 是数组,元素是事件对象(snake_case),分页信封 next_cursor / has_more
|
|
195
213
|
if ((0, output_1.isJsonMode)()) {
|
|
196
|
-
const items = visible.map((
|
|
197
|
-
event_id:
|
|
198
|
-
event_time:
|
|
199
|
-
target_table:
|
|
200
|
-
type:
|
|
201
|
-
operator:
|
|
202
|
-
summary:
|
|
203
|
-
|
|
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) } : {}),
|
|
204
224
|
}));
|
|
205
|
-
(0, output_1.emitPaged)(items, nextCursor, hasMore);
|
|
225
|
+
(0, output_1.emitPaged)(items, result.nextCursor, result.hasMore);
|
|
206
226
|
if (skipped.length > 0) {
|
|
207
227
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
208
228
|
}
|
|
@@ -225,23 +245,17 @@ async function handleDbAuditList(tables, opts) {
|
|
|
225
245
|
? ["target_table", "event_time", "type", "event_id", "operator", "summary"]
|
|
226
246
|
: ["event_time", "type", "event_id", "operator", "summary"];
|
|
227
247
|
const rows = visible.map((it) => {
|
|
228
|
-
const eventTime = (0, render_1.formatTime)(
|
|
229
|
-
|
|
230
|
-
const cells = [
|
|
231
|
-
|
|
232
|
-
asString(it.type),
|
|
233
|
-
eventId,
|
|
234
|
-
asString(it.operator, "—"),
|
|
235
|
-
asString(it.summary),
|
|
236
|
-
];
|
|
237
|
-
return isMultiTable ? [asString(it.target_table), ...cells] : cells;
|
|
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;
|
|
238
252
|
});
|
|
239
253
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
240
254
|
if (skipped.length > 0) {
|
|
241
255
|
process.stderr.write(`(${String(skipped.length)} table(s) skipped: ${skipped.join(", ")})\n`);
|
|
242
256
|
}
|
|
243
|
-
if (hasMore && nextCursor) {
|
|
244
|
-
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`);
|
|
245
259
|
}
|
|
246
260
|
}
|
|
247
261
|
// ── helpers ──
|
|
@@ -255,35 +269,17 @@ function normalizeTime(input, flagName) {
|
|
|
255
269
|
const ms = (0, index_1.parseTimeFilterMs)(input, flagName);
|
|
256
270
|
return new Date(ms).toISOString();
|
|
257
271
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
|
264
280
|
}
|
|
265
|
-
return out;
|
|
266
|
-
}
|
|
267
|
-
// SQL 字面量转义。表名 / 时间戳由调用方控制范围(白名单或严格 ISO 8601 正则)。
|
|
268
|
-
function escapeSqlLiteral(s) {
|
|
269
|
-
return s.replace(/'/g, "''");
|
|
270
281
|
}
|
|
271
282
|
// PRD 输出 enabled 列用 yes/no 而非 true/false
|
|
272
283
|
function boolToYesNo(b) {
|
|
273
284
|
return b ? "yes" : "no";
|
|
274
285
|
}
|
|
275
|
-
function asString(v, fallback = "") {
|
|
276
|
-
if (v == null)
|
|
277
|
-
return fallback;
|
|
278
|
-
if (typeof v === "string")
|
|
279
|
-
return v;
|
|
280
|
-
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint")
|
|
281
|
-
return String(v);
|
|
282
|
-
return JSON.stringify(v);
|
|
283
|
-
}
|
|
284
|
-
// audit list 的 event_id 在 TTY 视图里截断展示(PRD 示例 "01525416B44F...")
|
|
285
|
-
function truncateId(id) {
|
|
286
|
-
if (id.length <= 12)
|
|
287
|
-
return id;
|
|
288
|
-
return id.slice(0, 12) + "...";
|
|
289
|
-
}
|
|
@@ -42,45 +42,85 @@ 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
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
|
-
|
|
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
|
+
}
|
|
72
104
|
renderDiff(result);
|
|
73
105
|
}
|
|
74
106
|
async function handleDbMigrationApply(opts) {
|
|
75
107
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
76
108
|
// TTY 下先 diff 给用户审;--yes 直接打到 online
|
|
77
109
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
78
|
-
|
|
110
|
+
let preview;
|
|
111
|
+
try {
|
|
112
|
+
preview = await api.db.migrate({ appId, dryRun: true });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw decorateMigrationError(err);
|
|
116
|
+
}
|
|
79
117
|
if (preview.changes.length === 0) {
|
|
80
|
-
|
|
81
|
-
(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
});
|
|
84
124
|
}
|
|
85
125
|
renderDiff(preview);
|
|
86
126
|
const ok = await confirm(`? Apply ${String(preview.changes.length)} change(s) to ${preview.to}? (y/N) `);
|
|
@@ -89,7 +129,13 @@ async function handleDbMigrationApply(opts) {
|
|
|
89
129
|
return;
|
|
90
130
|
}
|
|
91
131
|
}
|
|
92
|
-
|
|
132
|
+
let result;
|
|
133
|
+
try {
|
|
134
|
+
result = await api.db.migrate({ appId, dryRun: false });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
throw decorateMigrationError(err);
|
|
138
|
+
}
|
|
93
139
|
if ((0, output_1.isJsonMode)()) {
|
|
94
140
|
// PRD:{"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}
|
|
95
141
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -134,6 +180,28 @@ function renderDiff(result) {
|
|
|
134
180
|
(0, output_1.emit)(`${result.from} ${arrow} ${result.to} (${String(result.changes.length)} changes):\n\n` +
|
|
135
181
|
result.changes.map((c) => ` ${c.statement}`).join("\n"));
|
|
136
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;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
137
205
|
async function confirm(prompt) {
|
|
138
206
|
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
139
207
|
return new Promise((resolve) => {
|
|
@@ -48,7 +48,13 @@ 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
|
-
|
|
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
|
+
}
|
|
52
58
|
renderDiff(result);
|
|
53
59
|
}
|
|
54
60
|
async function handleDbRecoveryApply(target, opts) {
|
|
@@ -56,7 +62,13 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
56
62
|
const ts = normalizeTimestamp(target);
|
|
57
63
|
// PITR 高危:TTY 下先 diff 给用户审;--yes 直接执行
|
|
58
64
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
59
|
-
|
|
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
|
+
}
|
|
60
72
|
renderDiff(preview);
|
|
61
73
|
const ok = await confirm(`? Restore database to ${preview.target}? This will overwrite current data. (y/N) `);
|
|
62
74
|
if (!ok) {
|
|
@@ -64,7 +76,13 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
64
76
|
return;
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
|
-
|
|
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
|
+
}
|
|
68
86
|
if ((0, output_1.isJsonMode)()) {
|
|
69
87
|
// PRD:{"status": "restored", "target": "...", "tables_affected": 2, "elapsed_seconds": 30}
|
|
70
88
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
@@ -82,21 +100,25 @@ async function handleDbRecoveryApply(target, opts) {
|
|
|
82
100
|
}
|
|
83
101
|
// ── helpers ──
|
|
84
102
|
/**
|
|
85
|
-
* 把用户传入的时间统一成 ISO 8601 UTC。
|
|
86
|
-
*
|
|
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。
|
|
87
110
|
*/
|
|
88
111
|
function normalizeTimestamp(input) {
|
|
89
|
-
|
|
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";
|
|
90
114
|
if (input === "") {
|
|
91
115
|
throw new error_1.AppError("ARGS_INVALID", "target timestamp is required", {
|
|
92
116
|
next_actions: ["Usage: miaoda db recovery diff|apply <timestamp>"],
|
|
93
117
|
});
|
|
94
118
|
}
|
|
119
|
+
// 仅日期:YYYY-MM-DD → 按 UTC 0 点
|
|
95
120
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(input)) {
|
|
99
|
-
const d = new Date(input.replace(" ", "T"));
|
|
121
|
+
const d = new Date(`${input}T00:00:00Z`);
|
|
100
122
|
if (Number.isNaN(d.getTime())) {
|
|
101
123
|
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
102
124
|
next_actions: [FORMAT_HINT],
|
|
@@ -104,13 +126,20 @@ function normalizeTimestamp(input) {
|
|
|
104
126
|
}
|
|
105
127
|
return d.toISOString();
|
|
106
128
|
}
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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();
|
|
112
138
|
}
|
|
113
|
-
|
|
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
|
+
});
|
|
114
143
|
}
|
|
115
144
|
// PRD diff 输出(结构化 prose):
|
|
116
145
|
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
@@ -177,6 +206,28 @@ function describeChange(c) {
|
|
|
177
206
|
parts.push(`~${String(c.modified)} rows modified`);
|
|
178
207
|
return parts.length === 0 ? "no changes" : parts.join(", ");
|
|
179
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;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
180
231
|
async function confirm(prompt) {
|
|
181
232
|
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
182
233
|
return new Promise((resolve) => {
|
package/dist/utils/http.js
CHANGED
|
@@ -42,7 +42,11 @@ function setRuntimeHttpClient(custom) {
|
|
|
42
42
|
}
|
|
43
43
|
function createClient(opts) {
|
|
44
44
|
const config = {
|
|
45
|
-
|
|
45
|
+
// 90s = dataloom admin-inner 异步轮询 asyncPollTimeout(60s) + 网络/解析 buffer(30s)。
|
|
46
|
+
// migration apply / recovery diff 等长轮询命令的服务端最长等 60s,CLI 超时短于
|
|
47
|
+
// 服务端会让请求被客户端 abort('Failed to recover: 0' / 'Request aborted'),
|
|
48
|
+
// 用户拿不到真实结果。普通命令 1-2s 就返,30→90s 全局上限对短查询无可感知影响。
|
|
49
|
+
timeout: 90_000,
|
|
46
50
|
platform: {
|
|
47
51
|
enabled: true,
|
|
48
52
|
adminInnerApi: opts.adminInnerApi,
|