@lark-apaas/miaoda-cli 0.1.2-alpha.4e370b6 → 0.1.2-alpha.51197c2
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 +67 -0
- package/dist/api/db/index.js +3 -1
- package/dist/cli/commands/db/index.js +333 -36
- package/dist/cli/handlers/db/migration.js +26 -4
- package/dist/cli/handlers/db/recovery.js +189 -48
- package/dist/utils/index.js +3 -1
- package/dist/utils/poll.js +27 -0
- package/package.json +1 -1
package/dist/api/db/api.js
CHANGED
|
@@ -10,7 +10,9 @@ exports.setAuditConfig = setAuditConfig;
|
|
|
10
10
|
exports.listAuditLog = listAuditLog;
|
|
11
11
|
exports.migrationInit = migrationInit;
|
|
12
12
|
exports.migrate = migrate;
|
|
13
|
+
exports.getMigrationStatus = getMigrationStatus;
|
|
13
14
|
exports.recover = recover;
|
|
15
|
+
exports.getRecoveryPreview = getRecoveryPreview;
|
|
14
16
|
exports.getDbQuota = getDbQuota;
|
|
15
17
|
const http_1 = require("../../utils/http");
|
|
16
18
|
const error_1 = require("../../utils/error");
|
|
@@ -33,6 +35,22 @@ async function mapDbHttpError(err, url, ctx,
|
|
|
33
35
|
onErrorBody) {
|
|
34
36
|
if (err instanceof error_1.AppError)
|
|
35
37
|
throw err;
|
|
38
|
+
// 客户端超时 / abort:SdkHttpError 时 response 通常缺失(status=0),落到 throw 时
|
|
39
|
+
// message 形如 'Failed to recover: 0' 让用户看不懂。识别 abort / timeout 关键字
|
|
40
|
+
// 转成专用错误码 + 友好 hint。
|
|
41
|
+
if (err instanceof Error) {
|
|
42
|
+
const msg = err.message.toLowerCase();
|
|
43
|
+
if (msg.includes("aborted") ||
|
|
44
|
+
msg.includes("timeout") ||
|
|
45
|
+
err.name === "AbortError" ||
|
|
46
|
+
err.name === "TimeoutError") {
|
|
47
|
+
throw new error_1.AppError("REQUEST_TIMEOUT", `${ctx}: request timed out`, {
|
|
48
|
+
next_actions: [
|
|
49
|
+
"Server-side async tasks may take up to 60s. Retry the command if the underlying task likely succeeded.",
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
36
54
|
if (err instanceof http_client_1.HttpError) {
|
|
37
55
|
const status = err.response?.status ?? 0;
|
|
38
56
|
const statusText = err.response?.statusText ?? "";
|
|
@@ -484,6 +502,31 @@ async function migrate(opts) {
|
|
|
484
502
|
const respBody = (await response.json());
|
|
485
503
|
return (0, client_1.extractData)(respBody);
|
|
486
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/migration/status?taskId=...
|
|
507
|
+
* CLI 拿到 migration apply 的 taskId 后定时调本接口,直到 status=success/failed。
|
|
508
|
+
* 网络层超时仍走 mapDbHttpError → 单次 30s;轮询节奏由 CLI handler 自行控制。
|
|
509
|
+
*/
|
|
510
|
+
async function getMigrationStatus(opts) {
|
|
511
|
+
const client = (0, http_1.getHttpClient)();
|
|
512
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/migration/status", {
|
|
513
|
+
taskId: opts.taskId,
|
|
514
|
+
dbBranch: opts.dbBranch,
|
|
515
|
+
});
|
|
516
|
+
const start = Date.now();
|
|
517
|
+
let response;
|
|
518
|
+
try {
|
|
519
|
+
response = await client.get(url);
|
|
520
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
524
|
+
await mapDbHttpError(err, url, "Failed to get migration status");
|
|
525
|
+
throw err; // 不可达
|
|
526
|
+
}
|
|
527
|
+
const body = (await response.json());
|
|
528
|
+
return (0, client_1.extractData)(body);
|
|
529
|
+
}
|
|
487
530
|
// ── db recovery → InnerAdminRecover ──
|
|
488
531
|
/**
|
|
489
532
|
* 后端:POST /v1/dataloom/app/{appId}/db/recovery
|
|
@@ -509,6 +552,30 @@ async function recover(opts) {
|
|
|
509
552
|
const respBody = (await response.json());
|
|
510
553
|
return (0, client_1.extractData)(respBody);
|
|
511
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* 后端:GET /v1/dataloom/app/{appId}/db/recovery/preview?previewRequestId=...
|
|
557
|
+
* CLI 拿到 recovery diff 的 previewRequestId 后定时调本接口直到 previewStatus=success/failed。
|
|
558
|
+
*/
|
|
559
|
+
async function getRecoveryPreview(opts) {
|
|
560
|
+
const client = (0, http_1.getHttpClient)();
|
|
561
|
+
const url = (0, client_1.buildInnerUrl)(opts.appId, "/db/recovery/preview", {
|
|
562
|
+
previewRequestId: opts.previewRequestId,
|
|
563
|
+
dbBranch: opts.dbBranch,
|
|
564
|
+
});
|
|
565
|
+
const start = Date.now();
|
|
566
|
+
let response;
|
|
567
|
+
try {
|
|
568
|
+
response = await client.get(url);
|
|
569
|
+
(0, client_1.traceHttp)("GET", url, start, response);
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
(0, client_1.traceHttp)("GET", url, start, err instanceof http_client_1.HttpError ? err.response : undefined, err);
|
|
573
|
+
await mapDbHttpError(err, url, "Failed to get recovery preview");
|
|
574
|
+
throw err; // 不可达
|
|
575
|
+
}
|
|
576
|
+
const body = (await response.json());
|
|
577
|
+
return (0, client_1.extractData)(body);
|
|
578
|
+
}
|
|
512
579
|
// ── db quota → InnerAdminGetDbQuota ──
|
|
513
580
|
/**
|
|
514
581
|
* 后端:GET /v1/dataloom/app/{appId}/db/quota?dbBranch=
|
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.listAuditLog = exports.setAuditConfig = exports.getAuditStatus = exports.listDDLChangelog = exports.exportData = exports.importData = exports.getSchema = exports.execSql = void 0;
|
|
3
|
+
exports.pickTableDetail = exports.flattenSchemaList = exports.parseSqlResult = exports.SQLSTATE_MAP = exports.ensureInnerSuccess = exports.buildInnerUrl = exports.getDbQuota = exports.getRecoveryPreview = exports.recover = exports.getMigrationStatus = exports.migrate = exports.migrationInit = exports.listAuditLog = exports.setAuditConfig = exports.getAuditStatus = exports.listDDLChangelog = exports.exportData = exports.importData = exports.getSchema = exports.execSql = void 0;
|
|
4
4
|
var api_1 = require("./api");
|
|
5
5
|
Object.defineProperty(exports, "execSql", { enumerable: true, get: function () { return api_1.execSql; } });
|
|
6
6
|
Object.defineProperty(exports, "getSchema", { enumerable: true, get: function () { return api_1.getSchema; } });
|
|
@@ -12,7 +12,9 @@ Object.defineProperty(exports, "setAuditConfig", { enumerable: true, get: functi
|
|
|
12
12
|
Object.defineProperty(exports, "listAuditLog", { enumerable: true, get: function () { return api_1.listAuditLog; } });
|
|
13
13
|
Object.defineProperty(exports, "migrationInit", { enumerable: true, get: function () { return api_1.migrationInit; } });
|
|
14
14
|
Object.defineProperty(exports, "migrate", { enumerable: true, get: function () { return api_1.migrate; } });
|
|
15
|
+
Object.defineProperty(exports, "getMigrationStatus", { enumerable: true, get: function () { return api_1.getMigrationStatus; } });
|
|
15
16
|
Object.defineProperty(exports, "recover", { enumerable: true, get: function () { return api_1.recover; } });
|
|
17
|
+
Object.defineProperty(exports, "getRecoveryPreview", { enumerable: true, get: function () { return api_1.getRecoveryPreview; } });
|
|
16
18
|
Object.defineProperty(exports, "getDbQuota", { enumerable: true, get: function () { return api_1.getDbQuota; } });
|
|
17
19
|
var client_1 = require("./client");
|
|
18
20
|
Object.defineProperty(exports, "buildInnerUrl", { enumerable: true, get: function () { return client_1.buildInnerUrl; } });
|
|
@@ -218,17 +218,49 @@ Examples:
|
|
|
218
218
|
dbCmd
|
|
219
219
|
.command("changelog")
|
|
220
220
|
.summary("查看 DDL 变更历史")
|
|
221
|
-
.description("
|
|
221
|
+
.description("查看 DDL 变更记录(建表、改表、删表等)。默认按时间倒序显示,可按表名或时间范围过滤。")
|
|
222
222
|
.usage("[flags]")
|
|
223
|
-
.option("--table <name>", "
|
|
224
|
-
.option("--since <time>", "
|
|
225
|
-
.option("--until <time>", "
|
|
226
|
-
.option("--limit <n>", "
|
|
227
|
-
.option("--cursor <token>", "
|
|
228
|
-
.option("--all", "
|
|
223
|
+
.option("--table <name>", "按表名过滤")
|
|
224
|
+
.option("--since <time>", "起始时间,支持 YYYY-MM-DD(按当日 00:00:00 UTC)/ ISO 8601(如 2026-04-01T10:00:00Z)/ 相对时间(1h、2d、1w)")
|
|
225
|
+
.option("--until <time>", "截止时间,格式同 --since")
|
|
226
|
+
.option("--limit <n>", "返回条数上限(默认 20)", parsePositiveInt, 20)
|
|
227
|
+
.option("--cursor <token>", "从上一页返回的游标位置继续获取")
|
|
228
|
+
.option("--all", "获取全部结果,自动翻页")
|
|
229
229
|
.action(async function () {
|
|
230
230
|
await (0, index_1.handleDbChangelog)(this.optsWithGlobals());
|
|
231
|
-
})
|
|
231
|
+
})
|
|
232
|
+
.addHelpText("after", `
|
|
233
|
+
Notes:
|
|
234
|
+
- DDL 变更由系统自动记录,不可关闭,无需单独开启。
|
|
235
|
+
- 默认输出展示摘要(summary),完整 SQL 原文(statement)通过 --json 获取。
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
# 列出近期变更(statement 截断展示,完整 SQL 用 --json 获取)
|
|
239
|
+
$ miaoda db changelog
|
|
240
|
+
change_id changed_at operator target_table change_type summary
|
|
241
|
+
1862462453263587 2026-04-16 13:24:59 zqy users ALTER_AUDIT_RETENTION 修改记录日志周期
|
|
242
|
+
1861814222210116 2026-04-08 20:12:37 zqy users CREATE_TABLE 创建并修改数据表
|
|
243
|
+
|
|
244
|
+
# 按条件过滤
|
|
245
|
+
$ miaoda db changelog --table users --since 2026-04-01
|
|
246
|
+
$ miaoda db changelog --limit 5
|
|
247
|
+
|
|
248
|
+
# JSON 输出包含完整 statement
|
|
249
|
+
$ miaoda db changelog --limit 1 --json
|
|
250
|
+
{
|
|
251
|
+
"data": [{
|
|
252
|
+
"change_id": "1861814222210116",
|
|
253
|
+
"changed_at": "2026-04-08T20:12:37Z",
|
|
254
|
+
"operator": "zqy",
|
|
255
|
+
"target_table": "users",
|
|
256
|
+
"change_type": "CREATE_TABLE",
|
|
257
|
+
"summary": "创建并修改数据表",
|
|
258
|
+
"statement": "CREATE TABLE ... ;"
|
|
259
|
+
}],
|
|
260
|
+
"next_cursor": null,
|
|
261
|
+
"has_more": false
|
|
262
|
+
}
|
|
263
|
+
`);
|
|
232
264
|
// ── audit ──
|
|
233
265
|
const auditCmd = dbCmd
|
|
234
266
|
.command("audit")
|
|
@@ -240,42 +272,136 @@ Examples:
|
|
|
240
272
|
});
|
|
241
273
|
auditCmd
|
|
242
274
|
.command("status")
|
|
243
|
-
.summary("
|
|
275
|
+
.summary("查看审计开关状态")
|
|
276
|
+
.description("查看表的审计开关状态。不指定表则显示全部。")
|
|
244
277
|
.usage("[table] [flags]")
|
|
245
278
|
.argument("[table]", "表名;省略时返所有已配置审计的表")
|
|
246
279
|
.action(async function (table) {
|
|
247
280
|
await (0, index_1.handleDbAuditStatus)(table, this.optsWithGlobals());
|
|
248
|
-
})
|
|
281
|
+
})
|
|
282
|
+
.addHelpText("after", `
|
|
283
|
+
Examples:
|
|
284
|
+
# 全部表
|
|
285
|
+
$ miaoda db audit status
|
|
286
|
+
table enabled enabled_at retention
|
|
287
|
+
users yes 2026-04-01 08:00:00 30d
|
|
288
|
+
orders no — —
|
|
289
|
+
products yes 2026-03-15 10:30:00 forever
|
|
290
|
+
|
|
291
|
+
# 单表
|
|
292
|
+
$ miaoda db audit status users
|
|
293
|
+
Table: users
|
|
294
|
+
Enabled: yes
|
|
295
|
+
Enabled at: 2026-04-01 08:00:00
|
|
296
|
+
Retention: 30d
|
|
297
|
+
|
|
298
|
+
# JSON
|
|
299
|
+
$ miaoda db audit status --json
|
|
300
|
+
$ miaoda db audit status users --json
|
|
301
|
+
`);
|
|
249
302
|
auditCmd
|
|
250
303
|
.command("enable")
|
|
251
304
|
.summary("启用表审计")
|
|
305
|
+
.description("开启指定表的审计,记录每次 INSERT / UPDATE / DELETE 操作。")
|
|
252
306
|
.usage("<table> [flags]")
|
|
253
307
|
.argument("<table>", "目标表名")
|
|
254
|
-
.option("--retention <
|
|
308
|
+
.option("--retention <period>", "审计日志保留时长,可选 7d / 30d / 180d / 360d / forever", "7d")
|
|
255
309
|
.action(async function (table) {
|
|
256
310
|
await (0, index_1.handleDbAuditEnable)(table, this.optsWithGlobals());
|
|
257
|
-
})
|
|
311
|
+
})
|
|
312
|
+
.addHelpText("after", `
|
|
313
|
+
Notes:
|
|
314
|
+
- 开启审计会占用额外存储空间,计入应用的存储配额。
|
|
315
|
+
- 用 \`miaoda db quota\` 查看用量;用 \`miaoda db audit disable\` 或调整 --retention 控制开销。
|
|
316
|
+
|
|
317
|
+
Examples:
|
|
318
|
+
# 默认保留 7 天
|
|
319
|
+
$ miaoda db audit enable orders
|
|
320
|
+
✓ Audit enabled for table 'orders' (retention: 7d)
|
|
321
|
+
|
|
322
|
+
# 指定保留时长 / 永久保留
|
|
323
|
+
$ miaoda db audit enable orders --retention 180d
|
|
324
|
+
$ miaoda db audit enable orders --retention forever
|
|
325
|
+
|
|
326
|
+
# JSON
|
|
327
|
+
$ miaoda db audit enable orders --retention 180d --json
|
|
328
|
+
|
|
329
|
+
# 报错:已经开启
|
|
330
|
+
$ miaoda db audit enable orders
|
|
331
|
+
Error: Audit is already enabled for table 'orders'
|
|
332
|
+
hint: Use \`miaoda db audit status orders\` to check current retention,
|
|
333
|
+
or run this command with a different --retention to update.
|
|
334
|
+
|
|
335
|
+
# 报错:retention 值非法
|
|
336
|
+
$ miaoda db audit enable orders --retention 60d
|
|
337
|
+
Error: Invalid retention '60d'
|
|
338
|
+
hint: Allowed values: 7d, 30d, 180d, 360d, forever.
|
|
339
|
+
`);
|
|
258
340
|
auditCmd
|
|
259
341
|
.command("disable")
|
|
260
342
|
.summary("关闭表审计")
|
|
343
|
+
.description("关闭指定表的审计。")
|
|
261
344
|
.usage("<table> [flags]")
|
|
262
345
|
.argument("<table>", "目标表名")
|
|
263
346
|
.action(async function (table) {
|
|
264
347
|
await (0, index_1.handleDbAuditDisable)(table, this.optsWithGlobals());
|
|
265
|
-
})
|
|
348
|
+
})
|
|
349
|
+
.addHelpText("after", `
|
|
350
|
+
Examples:
|
|
351
|
+
$ miaoda db audit disable orders
|
|
352
|
+
✓ Audit disabled for table 'orders'
|
|
353
|
+
|
|
354
|
+
# JSON
|
|
355
|
+
$ miaoda db audit disable orders --json
|
|
356
|
+
|
|
357
|
+
# 报错:未开启
|
|
358
|
+
$ miaoda db audit disable orders
|
|
359
|
+
Error: Audit is not enabled for table 'orders'
|
|
360
|
+
hint: Use \`miaoda db audit status\` to see which tables have audit enabled.
|
|
361
|
+
`);
|
|
266
362
|
auditCmd
|
|
267
363
|
.command("list")
|
|
268
364
|
.summary("查询审计日志")
|
|
269
|
-
.description("
|
|
365
|
+
.description("查询一个或多个表的审计日志记录。多表查询时输出会带 target_table 列标识每条记录所属的表。")
|
|
270
366
|
.usage("<table...> [flags]")
|
|
271
|
-
.argument("<tables...>", "
|
|
272
|
-
.option("--since <time>", "
|
|
273
|
-
.option("--until <time>", "
|
|
274
|
-
.option("--limit <n>", "
|
|
275
|
-
.option("--cursor <
|
|
367
|
+
.argument("<tables...>", "一个或多个表名")
|
|
368
|
+
.option("--since <time>", "起始时间,支持 YYYY-MM-DD / ISO 8601 / 相对时间(同 changelog --since)")
|
|
369
|
+
.option("--until <time>", "截止时间,格式同 --since")
|
|
370
|
+
.option("--limit <n>", "返回条数上限(默认 20)", parsePositiveInt, 20)
|
|
371
|
+
.option("--cursor <token>", "从上一页返回的游标位置继续获取")
|
|
372
|
+
.option("--all", "获取全部结果,自动翻页")
|
|
276
373
|
.action(async function (tables) {
|
|
277
374
|
await (0, index_1.handleDbAuditList)(tables, this.optsWithGlobals());
|
|
278
|
-
})
|
|
375
|
+
})
|
|
376
|
+
.addHelpText("after", `
|
|
377
|
+
Notes:
|
|
378
|
+
- 默认输出展示变更摘要(summary),完整变更快照(details 含 before / after)通过 --json 获取。
|
|
379
|
+
- 多表查询时不可用的表(未开启审计或不存在)会被跳过而非整体失败,全部不可用才返回错误。
|
|
380
|
+
- 退出码:只要有任何一张表有结果即为 0(跳过不算失败);全部不可用才为 1。
|
|
381
|
+
|
|
382
|
+
Examples:
|
|
383
|
+
# 单表
|
|
384
|
+
$ miaoda db audit list users --limit 5
|
|
385
|
+
|
|
386
|
+
# 多表(输出带 target_table 列区分来源)
|
|
387
|
+
$ miaoda db audit list users orders --limit 5
|
|
388
|
+
|
|
389
|
+
# 按时间范围
|
|
390
|
+
$ miaoda db audit list users --since 2026-04-14 --limit 10
|
|
391
|
+
|
|
392
|
+
# JSON(含完整 details / before / after)
|
|
393
|
+
$ miaoda db audit list users --limit 1 --json
|
|
394
|
+
|
|
395
|
+
# 报错:单表未开启
|
|
396
|
+
$ miaoda db audit list orders
|
|
397
|
+
Error: Audit is not enabled for table 'orders'
|
|
398
|
+
hint: Run \`miaoda db audit enable orders\` to start recording changes.
|
|
399
|
+
|
|
400
|
+
# 多表全部不可用才整体报错
|
|
401
|
+
$ miaoda db audit list orders invoices
|
|
402
|
+
Error: No audit data available for any of the specified tables
|
|
403
|
+
hint: Check audit status with \`miaoda db audit status\`.
|
|
404
|
+
`);
|
|
279
405
|
// ── migration ──
|
|
280
406
|
const migrationCmd = dbCmd
|
|
281
407
|
.command("migration")
|
|
@@ -287,28 +413,106 @@ Examples:
|
|
|
287
413
|
});
|
|
288
414
|
migrationCmd
|
|
289
415
|
.command("init")
|
|
290
|
-
.summary("
|
|
416
|
+
.summary("初始化多环境模式(dev / online)")
|
|
417
|
+
.description("初始化多环境模式(dev / online)。初始化后,dev 环境的数据库结构变更需要经 `migration apply` " +
|
|
418
|
+
"应用到 online;online 将无法直接更改数据库结构(仍可进行数据 DML 操作)。")
|
|
291
419
|
.usage("[flags]")
|
|
292
|
-
.option("--sync-data", "
|
|
293
|
-
.option("--yes", "
|
|
420
|
+
.option("--sync-data", "启用时将现有数据同步一份到 dev 环境")
|
|
421
|
+
.option("-y, --yes", "跳过确认提示直接执行")
|
|
294
422
|
.action(async function () {
|
|
295
423
|
await (0, index_1.handleDbMigrationInit)(this.optsWithGlobals());
|
|
296
|
-
})
|
|
424
|
+
})
|
|
425
|
+
.addHelpText("after", `
|
|
426
|
+
Notes:
|
|
427
|
+
- 多环境模式一旦启用无法恢复为单库模式,请确认后再启用。
|
|
428
|
+
|
|
429
|
+
Examples:
|
|
430
|
+
# TTY 下会要求确认
|
|
431
|
+
$ miaoda db migration init
|
|
432
|
+
? This action is irreversible. Initialize multi-env (dev / online)? (y/N) y
|
|
433
|
+
✓ Multi-env initialized (dev / online)
|
|
434
|
+
|
|
435
|
+
# --yes 跳过确认(Agent / CI 场景)
|
|
436
|
+
$ miaoda db migration init --sync-data --yes
|
|
437
|
+
✓ Multi-env initialized, data synced to dev
|
|
438
|
+
|
|
439
|
+
# JSON
|
|
440
|
+
$ miaoda db migration init --yes --json
|
|
441
|
+
{"data": {"status": "initialized", "environments": ["dev", "online"], "data_synced": false}}
|
|
442
|
+
|
|
443
|
+
# 报错:已经初始化过
|
|
444
|
+
$ miaoda db migration init
|
|
445
|
+
Error: Multi-env is already initialized
|
|
446
|
+
hint: Run \`miaoda db migration diff\` to view pending changes.
|
|
447
|
+
`);
|
|
297
448
|
migrationCmd
|
|
298
449
|
.command("diff")
|
|
299
450
|
.summary("预览 dev → online 待发布变更")
|
|
451
|
+
.description("预览 dev → online 的待发布变更。只读预览,不会实际发布。")
|
|
300
452
|
.usage("[flags]")
|
|
301
453
|
.action(async function () {
|
|
302
454
|
await (0, index_1.handleDbMigrationDiff)(this.optsWithGlobals());
|
|
303
|
-
})
|
|
455
|
+
})
|
|
456
|
+
.addHelpText("after", `
|
|
457
|
+
Examples:
|
|
458
|
+
$ miaoda db migration diff
|
|
459
|
+
dev → online (2 changes):
|
|
460
|
+
|
|
461
|
+
ALTER TABLE users ADD COLUMN avatar_url text;
|
|
462
|
+
CREATE INDEX idx_users_avatar ON users(avatar_url);
|
|
463
|
+
|
|
464
|
+
# JSON
|
|
465
|
+
$ miaoda db migration diff --json
|
|
466
|
+
|
|
467
|
+
# 报错:无待发布变更
|
|
468
|
+
$ miaoda db migration diff
|
|
469
|
+
Error: No pending changes between dev and online
|
|
470
|
+
hint: Make schema changes in dev first (e.g. \`miaoda db sql "ALTER TABLE ..." --env dev\`).
|
|
471
|
+
|
|
472
|
+
# 报错:多环境未初始化
|
|
473
|
+
$ miaoda db migration diff
|
|
474
|
+
Error: Multi-env is not initialized
|
|
475
|
+
hint: Run \`miaoda db migration init\` to set up multi-env first.
|
|
476
|
+
`);
|
|
304
477
|
migrationCmd
|
|
305
478
|
.command("apply")
|
|
306
|
-
.summary("
|
|
479
|
+
.summary("将 dev 的变更发布到 online(单事务原子)")
|
|
480
|
+
.description("将 dev 的变更发布到 online。")
|
|
307
481
|
.usage("[flags]")
|
|
308
|
-
.option("--yes", "
|
|
482
|
+
.option("-y, --yes", "跳过确认提示直接执行")
|
|
309
483
|
.action(async function () {
|
|
310
484
|
await (0, index_1.handleDbMigrationApply)(this.optsWithGlobals());
|
|
311
|
-
})
|
|
485
|
+
})
|
|
486
|
+
.addHelpText("after", `
|
|
487
|
+
Notes:
|
|
488
|
+
- 该操作会直接改动线上数据库,建议先用 \`migration diff\` 确认变更。
|
|
489
|
+
- 变更以单事务执行:多条 DDL 任何一条失败整批回滚,online 保持原状态。
|
|
490
|
+
- 退出码:0 表示全部成功;1 表示已回滚。
|
|
491
|
+
|
|
492
|
+
Examples:
|
|
493
|
+
# TTY 下会要求确认
|
|
494
|
+
$ miaoda db migration apply
|
|
495
|
+
? Apply 2 changes to online? (y/N) y
|
|
496
|
+
✓ Applied dev → online (2 changes)
|
|
497
|
+
|
|
498
|
+
# --yes 跳过确认
|
|
499
|
+
$ miaoda db migration apply --yes
|
|
500
|
+
✓ Applied dev → online (2 changes)
|
|
501
|
+
|
|
502
|
+
# JSON
|
|
503
|
+
$ miaoda db migration apply --yes --json
|
|
504
|
+
{"data": {"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}}
|
|
505
|
+
|
|
506
|
+
# 报错:无待发布变更
|
|
507
|
+
$ miaoda db migration apply
|
|
508
|
+
Error: No pending changes to apply
|
|
509
|
+
hint: Make schema changes in dev first, then run \`miaoda db migration diff\` to preview.
|
|
510
|
+
|
|
511
|
+
# 报错:多环境未启用
|
|
512
|
+
$ miaoda db migration apply
|
|
513
|
+
Error: Multi-env is not initialized
|
|
514
|
+
hint: Run \`miaoda db migration init\` to set up multi-env first.
|
|
515
|
+
`);
|
|
312
516
|
// ── recovery(PITR)──
|
|
313
517
|
const recoveryCmd = dbCmd
|
|
314
518
|
.command("recovery")
|
|
@@ -321,26 +525,119 @@ Examples:
|
|
|
321
525
|
recoveryCmd
|
|
322
526
|
.command("diff")
|
|
323
527
|
.summary("预览恢复到指定时间点的影响范围")
|
|
528
|
+
.description("预览恢复到指定时间点的影响范围(受影响的表、数据变更量、预计耗时)。只读预览,不改动数据。")
|
|
324
529
|
.usage("<timestamp> [flags]")
|
|
325
|
-
.argument("<timestamp>", "目标时间,YYYY-MM-DD
|
|
530
|
+
.argument("<timestamp>", "目标时间,YYYY-MM-DD 或 ISO 8601(如 2026-04-15T10:00:00Z)")
|
|
326
531
|
.action(async function (target) {
|
|
327
532
|
await (0, index_1.handleDbRecoveryDiff)(target, this.optsWithGlobals());
|
|
328
|
-
})
|
|
533
|
+
})
|
|
534
|
+
.addHelpText("after", `
|
|
535
|
+
Notes:
|
|
536
|
+
- 超出可恢复窗口的时间点会报错,错误信息中会标注当前可恢复窗口。
|
|
537
|
+
|
|
538
|
+
Examples:
|
|
539
|
+
# TTY pretty
|
|
540
|
+
$ miaoda db recovery diff 2026-04-15T10:00:00Z
|
|
541
|
+
Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
542
|
+
|
|
543
|
+
tables affected: 2
|
|
544
|
+
users: +3 rows, -1 row, ~5 rows modified
|
|
545
|
+
orders: table will be restored (was dropped at 10:25:00)
|
|
546
|
+
|
|
547
|
+
estimated time: ~30s
|
|
548
|
+
|
|
549
|
+
# non-TTY(管道,TAB 分列)
|
|
550
|
+
$ miaoda db recovery diff 2026-04-15T10:00:00Z | cat
|
|
551
|
+
Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
552
|
+
tables_affected 2
|
|
553
|
+
users +3 rows, -1 row, ~5 rows modified
|
|
554
|
+
orders table will be restored (was dropped at 10:25:00)
|
|
555
|
+
estimated_time 30
|
|
556
|
+
|
|
557
|
+
# JSON
|
|
558
|
+
$ miaoda db recovery diff 2026-04-15T10:00:00Z --json
|
|
559
|
+
|
|
560
|
+
# 无变更(目标时间点与当前状态一致)
|
|
561
|
+
$ miaoda db recovery diff 2026-04-15T14:00:00Z
|
|
562
|
+
Recovery preview (→ 2026-04-15T14:00:00Z):
|
|
563
|
+
|
|
564
|
+
No changes — database is already at this state.
|
|
565
|
+
|
|
566
|
+
# 超出可恢复窗口
|
|
567
|
+
$ miaoda db recovery diff 2026-04-05T10:00:00Z
|
|
568
|
+
Error: Timestamp is outside the recoverable window
|
|
569
|
+
hint: Current recoverable window: 2026-04-09T12:00:00Z ~ 2026-04-16T12:00:00Z
|
|
570
|
+
|
|
571
|
+
# 时间格式错误
|
|
572
|
+
$ miaoda db recovery diff 2026/04/15
|
|
573
|
+
Error: Invalid timestamp format '2026/04/15'
|
|
574
|
+
hint: Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z
|
|
575
|
+
`);
|
|
329
576
|
recoveryCmd
|
|
330
577
|
.command("apply")
|
|
331
|
-
.summary("
|
|
578
|
+
.summary("将数据库恢复到指定时间点的状态")
|
|
579
|
+
.description("将数据库恢复到指定时间点的状态。")
|
|
332
580
|
.usage("<timestamp> [flags]")
|
|
333
|
-
.argument("<timestamp>", "目标时间,YYYY-MM-DD
|
|
334
|
-
.option("--yes", "
|
|
581
|
+
.argument("<timestamp>", "目标时间,YYYY-MM-DD 或 ISO 8601(如 2026-04-15T10:00:00Z)")
|
|
582
|
+
.option("-y, --yes", "跳过确认提示直接执行")
|
|
335
583
|
.action(async function (target) {
|
|
336
584
|
await (0, index_1.handleDbRecoveryApply)(target, this.optsWithGlobals());
|
|
337
|
-
})
|
|
585
|
+
})
|
|
586
|
+
.addHelpText("after", `
|
|
587
|
+
Notes:
|
|
588
|
+
- 该操作会覆盖当前数据且不可撤销,建议先用 \`recovery diff\` 预览影响。
|
|
589
|
+
- 恢复过程是原子的:中途失败时数据库回到操作前的状态,不会出现部分恢复。
|
|
590
|
+
- 退出码:0 表示完成;1 表示已回滚。
|
|
591
|
+
|
|
592
|
+
Examples:
|
|
593
|
+
# TTY 下会要求确认
|
|
594
|
+
$ miaoda db recovery apply 2026-04-15T10:00:00Z
|
|
595
|
+
? Restore database to 2026-04-15T10:00:00Z? This will overwrite current data. (y/N) y
|
|
596
|
+
✓ Database restored to 2026-04-15T10:00:00Z (2 tables affected, 30s elapsed)
|
|
597
|
+
|
|
598
|
+
# --yes 跳过确认
|
|
599
|
+
$ miaoda db recovery apply 2026-04-15T10:00:00Z --yes
|
|
600
|
+
|
|
601
|
+
# JSON
|
|
602
|
+
$ miaoda db recovery apply 2026-04-15T10:00:00Z --yes --json
|
|
603
|
+
|
|
604
|
+
# 超出可恢复窗口
|
|
605
|
+
$ miaoda db recovery apply 2026-04-05T10:00:00Z
|
|
606
|
+
Error: Timestamp is outside the recoverable window
|
|
607
|
+
hint: Current recoverable window: 2026-04-09T12:00:00Z ~ 2026-04-16T12:00:00Z
|
|
608
|
+
Run \`miaoda db recovery diff <ts>\` within this window to preview impact.
|
|
609
|
+
|
|
610
|
+
# 并发冲突(已有恢复任务在执行)
|
|
611
|
+
$ miaoda db recovery apply 2026-04-15T10:00:00Z
|
|
612
|
+
Error: Another recovery is already in progress
|
|
613
|
+
hint: Wait for the current recovery to finish.
|
|
614
|
+
`);
|
|
338
615
|
// ── quota ──
|
|
339
616
|
dbCmd
|
|
340
617
|
.command("quota")
|
|
341
|
-
.summary("
|
|
618
|
+
.summary("查看数据库的存储用量与限额")
|
|
619
|
+
.description("查看数据库的存储用量、配额和表数量。")
|
|
342
620
|
.usage("[flags]")
|
|
343
621
|
.action(async function () {
|
|
344
622
|
await (0, index_1.handleDbQuota)(this.optsWithGlobals());
|
|
345
|
-
})
|
|
623
|
+
})
|
|
624
|
+
.addHelpText("after", `
|
|
625
|
+
Examples:
|
|
626
|
+
$ miaoda db quota
|
|
627
|
+
Storage: 14.9 MB / 1 GB (1.5%)
|
|
628
|
+
Tables: 3
|
|
629
|
+
Views: 10
|
|
630
|
+
|
|
631
|
+
# JSON
|
|
632
|
+
$ miaoda db quota --json
|
|
633
|
+
{
|
|
634
|
+
"data": {
|
|
635
|
+
"storage_used_bytes": 15623782,
|
|
636
|
+
"storage_quota_bytes": 1073741824,
|
|
637
|
+
"usage_percent": 1.5,
|
|
638
|
+
"tables": 3,
|
|
639
|
+
"views": 10
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
`);
|
|
346
643
|
}
|
|
@@ -44,6 +44,7 @@ const api = __importStar(require("../../../api/index"));
|
|
|
44
44
|
const shared_1 = require("../../../cli/commands/shared");
|
|
45
45
|
const error_1 = require("../../../utils/error");
|
|
46
46
|
const output_1 = require("../../../utils/output");
|
|
47
|
+
const poll_1 = require("../../../utils/poll");
|
|
47
48
|
const render_1 = require("../../../utils/render");
|
|
48
49
|
async function handleDbMigrationInit(opts) {
|
|
49
50
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
@@ -136,21 +137,42 @@ async function handleDbMigrationApply(opts) {
|
|
|
136
137
|
catch (err) {
|
|
137
138
|
throw decorateMigrationError(err);
|
|
138
139
|
}
|
|
140
|
+
// dataloom 立即返 taskId(apply 实际是异步流水线)。CLI 自己 poll 直到 success/failed,
|
|
141
|
+
// 避免单次 HTTP 长连接 30s+ 被网关 / SDK 中断。
|
|
142
|
+
if (result.taskId === undefined || result.taskId === "") {
|
|
143
|
+
throw new error_1.AppError("INTERNAL_DB_ERROR", "migration apply did not return taskId");
|
|
144
|
+
}
|
|
145
|
+
const taskId = result.taskId;
|
|
146
|
+
const final = await (0, poll_1.pollUntilDone)({
|
|
147
|
+
label: "migration apply",
|
|
148
|
+
intervalMs: 1000,
|
|
149
|
+
fetch: () => api.db.getMigrationStatus({ appId, taskId }),
|
|
150
|
+
isDone: (cur) => {
|
|
151
|
+
// 同 recovery preview,dataloom 上下游枚举大小写不完全统一,客户端归一兜底。
|
|
152
|
+
const status = cur.status.toLowerCase();
|
|
153
|
+
if (status === "success")
|
|
154
|
+
return { done: true, value: cur };
|
|
155
|
+
if (status === "failed") {
|
|
156
|
+
throw new error_1.AppError("DB_API_k_dl_1300030", cur.errorMessage ?? `migration apply failed (taskId=${taskId})`);
|
|
157
|
+
}
|
|
158
|
+
return { done: false };
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const appliedCount = final.changesApplied ?? result.changes.length;
|
|
139
162
|
if ((0, output_1.isJsonMode)()) {
|
|
140
163
|
// PRD:{"status": "applied", "from": "dev", "to": "online", "changes_applied": 2}
|
|
141
164
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
142
|
-
status:
|
|
165
|
+
status: "applied",
|
|
143
166
|
from: result.from,
|
|
144
167
|
to: result.to,
|
|
145
|
-
changesApplied:
|
|
168
|
+
changesApplied: appliedCount,
|
|
146
169
|
}));
|
|
147
170
|
return;
|
|
148
171
|
}
|
|
149
172
|
const tty = (0, render_1.isStdoutTty)();
|
|
150
173
|
const prefix = tty ? "✓" : "OK";
|
|
151
174
|
const arrow = tty ? "→" : "->";
|
|
152
|
-
|
|
153
|
-
(0, output_1.emit)(`${prefix} Applied ${result.from} ${arrow} ${result.to} (${String(applied)} changes)`);
|
|
175
|
+
(0, output_1.emit)(`${prefix} Applied ${result.from} ${arrow} ${result.to} (${String(appliedCount)} changes)`);
|
|
154
176
|
}
|
|
155
177
|
// ── helpers ──
|
|
156
178
|
// PRD diff 输出:
|
|
@@ -43,60 +43,121 @@ const api = __importStar(require("../../../api/index"));
|
|
|
43
43
|
const shared_1 = require("../../../cli/commands/shared");
|
|
44
44
|
const error_1 = require("../../../utils/error");
|
|
45
45
|
const output_1 = require("../../../utils/output");
|
|
46
|
+
const poll_1 = require("../../../utils/poll");
|
|
46
47
|
const render_1 = require("../../../utils/render");
|
|
47
48
|
// ── recovery diff ──
|
|
49
|
+
//
|
|
50
|
+
// PITR preview 是异步任务:dataloom 立即返 previewRequestId,CLI 自己 poll 直到 success/failed。
|
|
51
|
+
// 失败时 dataloom 已用 translateRestoreErr 把中文窗口错误翻成 PRD 英文 + k_dl_1300036 code,
|
|
52
|
+
// CLI 走 decorateRecoveryError 补 hint。
|
|
48
53
|
async function handleDbRecoveryDiff(target, opts) {
|
|
49
54
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
50
55
|
const ts = normalizeTimestamp(target);
|
|
51
|
-
const
|
|
52
|
-
renderDiff(
|
|
56
|
+
const preview = await runRecoveryPreview(appId, ts);
|
|
57
|
+
renderDiff(ts, preview);
|
|
53
58
|
}
|
|
54
59
|
async function handleDbRecoveryApply(target, opts) {
|
|
55
60
|
const appId = (0, shared_1.resolveAppId)(opts);
|
|
56
61
|
const ts = normalizeTimestamp(target);
|
|
57
62
|
// PITR 高危:TTY 下先 diff 给用户审;--yes 直接执行
|
|
58
63
|
if (!opts.yes && !(0, output_1.isJsonMode)()) {
|
|
59
|
-
const preview = await
|
|
60
|
-
renderDiff(preview);
|
|
61
|
-
const ok = await confirm(`? Restore database to ${
|
|
64
|
+
const preview = await runRecoveryPreview(appId, ts);
|
|
65
|
+
renderDiff(ts, preview);
|
|
66
|
+
const ok = await confirm(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
|
|
62
67
|
if (!ok) {
|
|
63
68
|
(0, output_1.emit)("Aborted.");
|
|
64
69
|
return;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
|
-
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await api.db.recover({ appId, target: ts, dryRun: false });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw decorateRecoveryError(err);
|
|
78
|
+
}
|
|
68
79
|
if ((0, output_1.isJsonMode)()) {
|
|
69
|
-
// PRD:{"status": "restored", "target": "...", "
|
|
80
|
+
// PRD:{"status": "restored", "target": "...", "elapsed_seconds": 30}
|
|
81
|
+
// tables_affected 来源于 preview,apply 路径不再回带,由 CLI 不强求字段。
|
|
70
82
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
71
83
|
status: result.status ?? "restored",
|
|
72
84
|
target: result.target,
|
|
73
|
-
tablesAffected: result.tablesAffected,
|
|
74
85
|
elapsedSeconds: result.elapsedSeconds ?? 0,
|
|
75
86
|
}));
|
|
76
87
|
return;
|
|
77
88
|
}
|
|
78
89
|
const tty = (0, render_1.isStdoutTty)();
|
|
79
90
|
const prefix = tty ? "✓" : "OK";
|
|
80
|
-
(0, output_1.emit)(`${prefix} Database
|
|
81
|
-
`(${String(result.
|
|
91
|
+
(0, output_1.emit)(`${prefix} Database restore triggered for ${result.target} ` +
|
|
92
|
+
`(${String(result.elapsedSeconds ?? 0)}s elapsed)`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 触发 PITR 预览任务并轮询到终态:
|
|
96
|
+
* 1. POST /db/recovery dryRun=true → previewRequestId
|
|
97
|
+
* 2. GET /db/recovery/preview?previewRequestId 直到 previewStatus=success/failed
|
|
98
|
+
*
|
|
99
|
+
* 错误透传:业务错误(窗口超限 / 格式错)由 dataloom 直接 throw,CLI 在 catch 里
|
|
100
|
+
* 补 hint。failed 状态下 dataloom 已把 errorMessage 翻好,CLI 包成 k_dl_1300036
|
|
101
|
+
* 让 decorateRecoveryError 命中窗口超限 hint。
|
|
102
|
+
*/
|
|
103
|
+
async function runRecoveryPreview(appId, ts) {
|
|
104
|
+
let triggered;
|
|
105
|
+
try {
|
|
106
|
+
triggered = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
throw decorateRecoveryError(err);
|
|
110
|
+
}
|
|
111
|
+
if (triggered.previewRequestId === undefined || triggered.previewRequestId === "") {
|
|
112
|
+
throw new error_1.AppError("INTERNAL_DB_ERROR", "recovery diff did not return previewRequestId");
|
|
113
|
+
}
|
|
114
|
+
const previewRequestId = triggered.previewRequestId;
|
|
115
|
+
try {
|
|
116
|
+
return await (0, poll_1.pollUntilDone)({
|
|
117
|
+
label: "recovery preview",
|
|
118
|
+
intervalMs: 1000,
|
|
119
|
+
fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
|
|
120
|
+
isDone: (cur) => {
|
|
121
|
+
// dataloom 内部 pgsvc 返回首字母大写枚举(Pending/Running/Success/Failed),
|
|
122
|
+
// 但 admin-inner thrift 契约里写的是小写。客户端做大小写归一兜底,避免依赖
|
|
123
|
+
// 上游 case 一致性导致死循环。
|
|
124
|
+
const status = cur.previewStatus.toLowerCase();
|
|
125
|
+
if (status === "success")
|
|
126
|
+
return { done: true, value: cur };
|
|
127
|
+
if (status === "failed") {
|
|
128
|
+
// 复用 k_dl_1300036(窗口超限 / 预览失败的统一对外码),让 decorateRecoveryError
|
|
129
|
+
// 命中后给 PITR 7 天窗口的 hint。errorMessage 优先用 dataloom 翻好的 PRD 文案。
|
|
130
|
+
throw new error_1.AppError("DB_API_k_dl_1300036", cur.errorMessage ?? "recovery preview failed");
|
|
131
|
+
}
|
|
132
|
+
return { done: false };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
throw decorateRecoveryError(err);
|
|
138
|
+
}
|
|
82
139
|
}
|
|
83
140
|
// ── helpers ──
|
|
84
141
|
/**
|
|
85
|
-
* 把用户传入的时间统一成 ISO 8601 UTC。
|
|
86
|
-
*
|
|
142
|
+
* 把用户传入的时间统一成 ISO 8601 UTC。PRD 只接受两种格式:
|
|
143
|
+
* - `YYYY-MM-DD`(按 UTC 0 点解释)
|
|
144
|
+
* - 完整 ISO 8601 `YYYY-MM-DDTHH:MM:SS[Z|±HH:MM]`
|
|
145
|
+
*
|
|
146
|
+
* 严格 regex 匹配 + 不用 new Date(input) 兜底——Node Date 解析 `2026/04/15` 这种
|
|
147
|
+
* 非 ISO 字符串居然成功(落到本地时区),绕过格式校验把脏值送到后端,导致用户拿到
|
|
148
|
+
* 「窗口超限」错误而不是「格式错误」。PRD 明确这两种格式以外的都应该报 INVALID_TIMESTAMP。
|
|
87
149
|
*/
|
|
88
150
|
function normalizeTimestamp(input) {
|
|
89
|
-
|
|
151
|
+
// PRD hint 文案对齐 PRD 截图(同时是 dataloom 端 ErrInvalidTimestamp 的 hint)
|
|
152
|
+
const FORMAT_HINT = "Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z";
|
|
90
153
|
if (input === "") {
|
|
91
154
|
throw new error_1.AppError("ARGS_INVALID", "target timestamp is required", {
|
|
92
155
|
next_actions: ["Usage: miaoda db recovery diff|apply <timestamp>"],
|
|
93
156
|
});
|
|
94
157
|
}
|
|
158
|
+
// 仅日期:YYYY-MM-DD → 按 UTC 0 点
|
|
95
159
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(input)) {
|
|
99
|
-
const d = new Date(input.replace(" ", "T"));
|
|
160
|
+
const d = new Date(`${input}T00:00:00Z`);
|
|
100
161
|
if (Number.isNaN(d.getTime())) {
|
|
101
162
|
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
102
163
|
next_actions: [FORMAT_HINT],
|
|
@@ -104,15 +165,24 @@ function normalizeTimestamp(input) {
|
|
|
104
165
|
}
|
|
105
166
|
return d.toISOString();
|
|
106
167
|
}
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
168
|
+
// 完整 ISO 8601 datetime:T 分隔,秒可选 ms,Z 或 ±HH:MM 时区
|
|
169
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/.test(input)) {
|
|
170
|
+
const d = new Date(input);
|
|
171
|
+
if (Number.isNaN(d.getTime())) {
|
|
172
|
+
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
173
|
+
next_actions: [FORMAT_HINT],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return d.toISOString();
|
|
112
177
|
}
|
|
113
|
-
|
|
178
|
+
// 其它一律按格式错误拒绝(`2026/04/15` / `2026.04.15` / `2026年4月15日` 等都走这里)
|
|
179
|
+
throw new error_1.AppError("INVALID_TIMESTAMP", `Invalid timestamp format '${input}'`, {
|
|
180
|
+
next_actions: [FORMAT_HINT],
|
|
181
|
+
});
|
|
114
182
|
}
|
|
115
|
-
// PRD diff
|
|
183
|
+
// PRD diff 输出,三套:
|
|
184
|
+
//
|
|
185
|
+
// TTY pretty(缩进 prose、带 Unicode 箭头 / 波浪号、表名按列对齐):
|
|
116
186
|
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
117
187
|
//
|
|
118
188
|
// tables affected: 2
|
|
@@ -120,12 +190,24 @@ function normalizeTimestamp(input) {
|
|
|
120
190
|
// orders: table will be restored (was dropped at 10:25:00)
|
|
121
191
|
//
|
|
122
192
|
// estimated time: ~30s
|
|
123
|
-
|
|
193
|
+
//
|
|
194
|
+
// non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
|
|
195
|
+
// Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
196
|
+
// tables_affected\t2
|
|
197
|
+
// users\t+3 rows, -1 row, ~5 rows modified
|
|
198
|
+
// orders\ttable will be restored (was dropped at 10:25:00)
|
|
199
|
+
// estimated_time\t30
|
|
200
|
+
//
|
|
201
|
+
// --json:标准 envelope,字段名固定 snake_case。
|
|
202
|
+
function renderDiff(target, preview) {
|
|
203
|
+
const changes = preview.changes ?? [];
|
|
204
|
+
const tablesAffected = preview.tablesAffected ?? changes.length;
|
|
205
|
+
const estimated = preview.estimatedSeconds;
|
|
124
206
|
if ((0, output_1.isJsonMode)()) {
|
|
125
207
|
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
126
|
-
target
|
|
127
|
-
tablesAffected
|
|
128
|
-
changes:
|
|
208
|
+
target,
|
|
209
|
+
tablesAffected,
|
|
210
|
+
changes: changes.map((c) => ({
|
|
129
211
|
table: c.table,
|
|
130
212
|
inserted: c.inserted,
|
|
131
213
|
deleted: c.deleted,
|
|
@@ -133,40 +215,77 @@ function renderDiff(result) {
|
|
|
133
215
|
action: c.action,
|
|
134
216
|
droppedAt: c.droppedAt,
|
|
135
217
|
})),
|
|
136
|
-
estimatedSeconds:
|
|
218
|
+
estimatedSeconds: estimated ?? 0,
|
|
137
219
|
}));
|
|
138
220
|
return;
|
|
139
221
|
}
|
|
140
222
|
const tty = (0, render_1.isStdoutTty)();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
(0, output_1.emit)(`Recovery preview (${arrow} ${result.target}):\n\n` +
|
|
144
|
-
` No changes — database is already at this state.`);
|
|
223
|
+
if (tty) {
|
|
224
|
+
(0, output_1.emit)(renderDiffTty(target, changes, tablesAffected, estimated));
|
|
145
225
|
return;
|
|
146
226
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
(0, output_1.emit)(renderDiffPipe(target, changes, tablesAffected, estimated));
|
|
228
|
+
}
|
|
229
|
+
function renderDiffTty(target, changes, tablesAffected, estimated) {
|
|
230
|
+
const header = `Recovery preview (→ ${target}):`;
|
|
231
|
+
if (changes.length === 0) {
|
|
232
|
+
return `${header}\n\n No changes — database is already at this state.`;
|
|
233
|
+
}
|
|
234
|
+
// 对齐:所有表名右侧补到 max(len) + 1 个 ":" + 1 个空格再起描述。PRD 用例里
|
|
235
|
+
// "users: " / "orders: " 三/二空格其实就是按 6 个 char 列宽对齐出来的。
|
|
236
|
+
const maxName = Math.max(...changes.map((c) => c.table.length));
|
|
237
|
+
const lines = [header, "", ` tables affected: ${String(tablesAffected)}`];
|
|
238
|
+
for (const c of changes) {
|
|
239
|
+
const pad = " ".repeat(Math.max(1, maxName - c.table.length + 2));
|
|
240
|
+
lines.push(` ${c.table}:${pad}${describeChange(c)}`);
|
|
154
241
|
}
|
|
155
|
-
if (
|
|
242
|
+
if (estimated !== undefined) {
|
|
156
243
|
lines.push("");
|
|
157
|
-
lines.push(` estimated time: ~${String(
|
|
244
|
+
lines.push(` estimated time: ~${String(estimated)}s`);
|
|
245
|
+
}
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
function renderDiffPipe(target, changes, tablesAffected, estimated) {
|
|
249
|
+
// 管道场景:去掉缩进,TAB 分列,箭头降级为 ->,key 用 snake_case,
|
|
250
|
+
// estimated_time 只输出秒数(不带 ~/s),方便 awk/grep。
|
|
251
|
+
const header = `Recovery preview (-> ${target}):`;
|
|
252
|
+
if (changes.length === 0) {
|
|
253
|
+
return `${header}\nNo changes — database is already at this state.`;
|
|
254
|
+
}
|
|
255
|
+
const lines = [header, `tables_affected\t${String(tablesAffected)}`];
|
|
256
|
+
for (const c of changes) {
|
|
257
|
+
lines.push(`${c.table}\t${describeChange(c)}`);
|
|
258
|
+
}
|
|
259
|
+
if (estimated !== undefined) {
|
|
260
|
+
lines.push(`estimated_time\t${String(estimated)}`);
|
|
158
261
|
}
|
|
159
|
-
|
|
262
|
+
return lines.join("\n");
|
|
160
263
|
}
|
|
161
264
|
function describeChange(c) {
|
|
162
|
-
|
|
265
|
+
// 表级操作:dataloom 现在把 schema diff 透出为 action="schema_<diffType>",
|
|
266
|
+
// 老协议的 restore_table / drop / create 一并保留兼容。
|
|
267
|
+
if (c.action === "restore_table") {
|
|
268
|
+
const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
|
|
269
|
+
return `table will be restored${ts}`;
|
|
270
|
+
}
|
|
271
|
+
if (c.action === "drop") {
|
|
272
|
+
const ts = c.droppedAt ? ` (was dropped at ${c.droppedAt})` : "";
|
|
273
|
+
return `table will be dropped${ts}`;
|
|
274
|
+
}
|
|
275
|
+
if (c.action === "create") {
|
|
163
276
|
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
277
|
return `table will be created${ts}`;
|
|
169
278
|
}
|
|
279
|
+
if (c.action?.startsWith("schema_") === true) {
|
|
280
|
+
// schema diff 子类不细化,统一显示 "schema changed (<diffType>)"
|
|
281
|
+
const diffType = c.action.slice("schema_".length);
|
|
282
|
+
return `schema changed${diffType !== "" ? ` (${diffType})` : ""}`;
|
|
283
|
+
}
|
|
284
|
+
if (c.action === "unavailable") {
|
|
285
|
+
// dataloom 端 count 失败的表,复用 droppedAt 透传 message
|
|
286
|
+
return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== "" ? `: ${c.droppedAt}` : ""}`;
|
|
287
|
+
}
|
|
288
|
+
// 数据变更:+N rows / -N rows / ~N rows modified
|
|
170
289
|
const parts = [];
|
|
171
290
|
if (c.inserted !== undefined && c.inserted !== 0)
|
|
172
291
|
parts.push(`+${String(c.inserted)} rows`);
|
|
@@ -177,6 +296,28 @@ function describeChange(c) {
|
|
|
177
296
|
parts.push(`~${String(c.modified)} rows modified`);
|
|
178
297
|
return parts.length === 0 ? "no changes" : parts.join(", ");
|
|
179
298
|
}
|
|
299
|
+
// decorateRecoveryError 给 recovery 路径上的几个错误码补 PRD 规定的 hint。
|
|
300
|
+
// dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
|
|
301
|
+
function decorateRecoveryError(err) {
|
|
302
|
+
if (!(err instanceof error_1.AppError))
|
|
303
|
+
return err;
|
|
304
|
+
switch (err.code) {
|
|
305
|
+
case "DB_API_k_dl_1300036":
|
|
306
|
+
// 窗口超限:引导用户检查 last migration apply 时间
|
|
307
|
+
return new error_1.AppError(err.code, err.message, {
|
|
308
|
+
next_actions: [
|
|
309
|
+
"PITR window is up to 7 days back, limited by your last `db migration apply` time.",
|
|
310
|
+
],
|
|
311
|
+
});
|
|
312
|
+
case "DB_API_k_dl_1300038":
|
|
313
|
+
// 时间格式错误:引导 ISO 8601
|
|
314
|
+
return new error_1.AppError(err.code, err.message, {
|
|
315
|
+
next_actions: ["Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z"],
|
|
316
|
+
});
|
|
317
|
+
default:
|
|
318
|
+
return err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
180
321
|
async function confirm(prompt) {
|
|
181
322
|
const rl = node_readline_1.default.createInterface({ input: process.stdin, output: process.stderr });
|
|
182
323
|
return new Promise((resolve) => {
|
package/dist/utils/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.setRuntimeHttpClient = exports.setHttpClient = exports.resetHttpClient = exports.getRuntimeHttpClient = exports.getHttpClient = exports.log = exports.debug = exports.isJsonMode = exports.emitError = exports.emit = exports.getLogId = exports.generateLogId = exports.initConfigFromOpts = exports.resetConfig = exports.setConfig = exports.getConfig = exports.HttpError = exports.AppError = void 0;
|
|
3
|
+
exports.pollUntilDone = exports.setRuntimeHttpClient = exports.setHttpClient = exports.resetHttpClient = exports.getRuntimeHttpClient = exports.getHttpClient = exports.log = exports.debug = exports.isJsonMode = exports.emitError = exports.emit = exports.getLogId = exports.generateLogId = exports.initConfigFromOpts = exports.resetConfig = exports.setConfig = exports.getConfig = exports.HttpError = exports.AppError = void 0;
|
|
4
4
|
var error_1 = require("./error");
|
|
5
5
|
Object.defineProperty(exports, "AppError", { enumerable: true, get: function () { return error_1.AppError; } });
|
|
6
6
|
Object.defineProperty(exports, "HttpError", { enumerable: true, get: function () { return error_1.HttpError; } });
|
|
@@ -25,3 +25,5 @@ Object.defineProperty(exports, "getRuntimeHttpClient", { enumerable: true, get:
|
|
|
25
25
|
Object.defineProperty(exports, "resetHttpClient", { enumerable: true, get: function () { return http_1.resetHttpClient; } });
|
|
26
26
|
Object.defineProperty(exports, "setHttpClient", { enumerable: true, get: function () { return http_1.setHttpClient; } });
|
|
27
27
|
Object.defineProperty(exports, "setRuntimeHttpClient", { enumerable: true, get: function () { return http_1.setRuntimeHttpClient; } });
|
|
28
|
+
var poll_1 = require("./poll");
|
|
29
|
+
Object.defineProperty(exports, "pollUntilDone", { enumerable: true, get: function () { return poll_1.pollUntilDone; } });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pollUntilDone = pollUntilDone;
|
|
4
|
+
const error_1 = require("../utils/error");
|
|
5
|
+
async function pollUntilDone(opts) {
|
|
6
|
+
const interval = opts.intervalMs ?? 1000;
|
|
7
|
+
const timeout = opts.timeoutMs ?? 300_000;
|
|
8
|
+
const deadline = Date.now() + timeout;
|
|
9
|
+
// 立即拉一次(绝大多数轻量任务在 dataloom 端已是同步语义,第一次 fetch 就能拿到 success)
|
|
10
|
+
for (;;) {
|
|
11
|
+
const cur = await opts.fetch();
|
|
12
|
+
const verdict = opts.isDone(cur);
|
|
13
|
+
if (verdict.done)
|
|
14
|
+
return verdict.value;
|
|
15
|
+
if (Date.now() + interval > deadline) {
|
|
16
|
+
throw new error_1.AppError("TASK_TIMEOUT", `${opts.label} did not complete within ${String(Math.round(timeout / 1000))}s`, {
|
|
17
|
+
next_actions: [
|
|
18
|
+
"The task may still be running server-side. Retry the command, or check `miaoda db migration diff` to verify final state.",
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
await sleep(interval);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|