@lark-apaas/miaoda-cli 0.1.3 → 0.1.4
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/app/api.js +3 -3
- package/dist/api/app/schemas.js +43 -43
- package/dist/api/db/api.js +398 -55
- package/dist/api/db/client.js +155 -28
- package/dist/api/db/index.js +12 -1
- package/dist/api/db/parsers.js +20 -20
- package/dist/api/db/sql-keywords.js +87 -87
- package/dist/api/deploy/api.js +5 -5
- package/dist/api/deploy/schemas.js +32 -32
- package/dist/api/file/api.js +89 -87
- package/dist/api/file/client.js +62 -22
- package/dist/api/file/detect.js +3 -3
- package/dist/api/file/index.js +2 -1
- package/dist/api/file/parsers.js +18 -7
- package/dist/api/observability/api.js +6 -6
- package/dist/api/observability/schemas.js +14 -14
- package/dist/api/plugin/api.js +31 -31
- package/dist/cli/commands/app/index.js +12 -12
- package/dist/cli/commands/db/index.js +602 -54
- package/dist/cli/commands/deploy/index.js +28 -28
- package/dist/cli/commands/file/index.js +85 -58
- package/dist/cli/commands/observability/index.js +69 -69
- package/dist/cli/commands/plugin/index.js +27 -27
- package/dist/cli/commands/shared.js +10 -10
- package/dist/cli/handlers/app/update.js +2 -2
- package/dist/cli/handlers/db/_destructive.js +67 -0
- package/dist/cli/handlers/db/_env.js +26 -0
- package/dist/cli/handlers/db/_operator.js +35 -0
- package/dist/cli/handlers/db/audit.js +383 -0
- package/dist/cli/handlers/db/changelog.js +160 -0
- package/dist/cli/handlers/db/data.js +32 -31
- package/dist/cli/handlers/db/index.js +17 -1
- package/dist/cli/handlers/db/migration.js +234 -0
- package/dist/cli/handlers/db/quota.js +68 -0
- package/dist/cli/handlers/db/recovery.js +413 -0
- package/dist/cli/handlers/db/schema.js +33 -33
- package/dist/cli/handlers/db/sql.js +69 -69
- package/dist/cli/handlers/deploy/deploy.js +4 -4
- package/dist/cli/handlers/deploy/error-log.js +1 -1
- package/dist/cli/handlers/deploy/get.js +3 -3
- package/dist/cli/handlers/deploy/polling.js +11 -11
- package/dist/cli/handlers/file/cp.js +30 -30
- package/dist/cli/handlers/file/index.js +3 -1
- package/dist/cli/handlers/file/ls.js +5 -5
- package/dist/cli/handlers/file/quota.js +66 -0
- package/dist/cli/handlers/file/rm.js +32 -30
- package/dist/cli/handlers/file/sign.js +3 -3
- package/dist/cli/handlers/file/stat.js +10 -9
- package/dist/cli/handlers/observability/analytics.js +47 -47
- package/dist/cli/handlers/observability/helpers.js +2 -2
- package/dist/cli/handlers/observability/log.js +9 -9
- package/dist/cli/handlers/observability/metric.js +26 -26
- package/dist/cli/handlers/observability/trace.js +5 -5
- package/dist/cli/handlers/plugin/plugin-local.js +53 -53
- package/dist/cli/handlers/plugin/plugin.js +15 -15
- package/dist/cli/help.js +16 -16
- package/dist/main.js +12 -12
- package/dist/utils/args.js +1 -1
- package/dist/utils/colors.js +2 -2
- package/dist/utils/config.js +2 -2
- package/dist/utils/devops-error.js +9 -9
- package/dist/utils/error.js +2 -2
- package/dist/utils/git.js +4 -4
- package/dist/utils/http.js +19 -19
- package/dist/utils/index.js +3 -1
- package/dist/utils/output.js +67 -45
- package/dist/utils/poll.js +35 -0
- package/dist/utils/render.js +27 -27
- package/dist/utils/spinner.js +46 -0
- package/dist/utils/time.js +47 -42
- package/package.json +1 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.handleDbRecoveryDiff = handleDbRecoveryDiff;
|
|
37
|
+
exports.handleDbRecoveryApply = handleDbRecoveryApply;
|
|
38
|
+
exports.formatRecoveryTargetForDisplay = formatRecoveryTargetForDisplay;
|
|
39
|
+
const api = __importStar(require("../../../api/index"));
|
|
40
|
+
const shared_1 = require("../../../cli/commands/shared");
|
|
41
|
+
const colors_1 = require("../../../utils/colors");
|
|
42
|
+
const error_1 = require("../../../utils/error");
|
|
43
|
+
const output_1 = require("../../../utils/output");
|
|
44
|
+
const poll_1 = require("../../../utils/poll");
|
|
45
|
+
const render_1 = require("../../../utils/render");
|
|
46
|
+
const time_1 = require("../../../utils/time");
|
|
47
|
+
const _destructive_1 = require("../../../cli/handlers/db/_destructive");
|
|
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。
|
|
53
|
+
async function handleDbRecoveryDiff(target, opts) {
|
|
54
|
+
const appId = (0, shared_1.resolveAppId)(opts);
|
|
55
|
+
const ts = normalizeTimestamp(target);
|
|
56
|
+
const preview = await runRecoveryPreview(appId, ts, target);
|
|
57
|
+
renderDiff(ts, preview);
|
|
58
|
+
}
|
|
59
|
+
async function handleDbRecoveryApply(target, opts) {
|
|
60
|
+
const appId = (0, shared_1.resolveAppId)(opts);
|
|
61
|
+
const ts = normalizeTimestamp(target);
|
|
62
|
+
// PRD 要求 apply 输出包含 N tables affected / Ms elapsed。
|
|
63
|
+
// tables_affected 从 preview 拿;elapsed 用 CLI 端墙钟(dataloom apply 是异步
|
|
64
|
+
// 触发,server-side elapsed=0;下面 poll 真终态后用本地计时器算 elapsed)。
|
|
65
|
+
const preview = await runRecoveryPreview(appId, ts, target);
|
|
66
|
+
const tablesAffected = preview.tablesAffected ?? preview.changes?.length ?? 0;
|
|
67
|
+
// 0 changes 短路:目标时间点与当前一致,apply 没意义。pretty 模式渲染 preview
|
|
68
|
+
// 的 "No changes — database is already at this state." 后直接退;--json 模式
|
|
69
|
+
// 返 status="no_changes" envelope 让下游识别。不进 confirm 也不下发 apply。
|
|
70
|
+
if ((preview.changes?.length ?? 0) === 0) {
|
|
71
|
+
if ((0, output_1.isJsonMode)()) {
|
|
72
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
73
|
+
status: 'no_changes',
|
|
74
|
+
target: ts,
|
|
75
|
+
tablesAffected: 0,
|
|
76
|
+
elapsedSeconds: 0,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
renderDiff(ts, preview);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// --yes 跳过;非 TTY 抛 DESTRUCTIVE_REQUIRES_CONFIRM;TTY 渲染 diff(pretty)+ 交互确认。
|
|
85
|
+
// 不再用 isJsonMode() 做门 —— 见 _destructive.ts 注释。
|
|
86
|
+
if (opts.yes !== true) {
|
|
87
|
+
(0, _destructive_1.assertDestructiveAllowedInTty)(opts.yes);
|
|
88
|
+
// --json 模式跳过 pretty diff 渲染避免污染 stdout envelope;TTY pretty 模式渲染
|
|
89
|
+
if (!(0, output_1.isJsonMode)())
|
|
90
|
+
renderDiff(ts, preview);
|
|
91
|
+
const ok = await (0, _destructive_1.askYesNo)(`? Restore database to ${ts}? This will overwrite current data. (y/N) `);
|
|
92
|
+
if (!ok) {
|
|
93
|
+
(0, output_1.emit)('Aborted.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await api.db.recover({ appId, target: ts, dryRun: false });
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw decorateRecoveryError(err);
|
|
103
|
+
}
|
|
104
|
+
// 关键:墙钟从 apply 触发瞬间开始,poll 真完成时停。dataloom restore 是异步触发,
|
|
105
|
+
// 立即返 status="restore_triggered",CLI 必须 poll GET /db/recovery/status 拿
|
|
106
|
+
// Redis 缓存的 Resetting/Ready/Failed,否则用户会被误导以为已恢复完。
|
|
107
|
+
const startedAt = Date.now();
|
|
108
|
+
await waitRecoveryDone(appId, ts, target);
|
|
109
|
+
const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
110
|
+
if ((0, output_1.isJsonMode)()) {
|
|
111
|
+
// PRD:{"status": "restored", "target": "...", "tables_affected": N, "elapsed_seconds": M}
|
|
112
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
113
|
+
status: 'restored',
|
|
114
|
+
target: result.target,
|
|
115
|
+
tablesAffected,
|
|
116
|
+
elapsedSeconds: elapsed,
|
|
117
|
+
}));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
121
|
+
const body = `Database restored to ${result.target} ` +
|
|
122
|
+
`(${String(tablesAffected)} tables affected, ${String(elapsed)}s elapsed)`;
|
|
123
|
+
(0, output_1.emit)(tty ? colors_1.c.success(`✓ ${body}`) : `OK ${body}`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 触发 BranchRestore 后 poll 直到任务终态。dataloom 端 Redis 缓存 workspace 级
|
|
127
|
+
* status,归一成 running / success / failed:
|
|
128
|
+
* - running → 继续 poll
|
|
129
|
+
* - success → 退出,CLI 渲染 ✓
|
|
130
|
+
* - failed → 抛 DB_API_k_dl_1300036 + errorMessage
|
|
131
|
+
*
|
|
132
|
+
* 不设固定超时:BranchRestore 实际时长依库大小变化(前端文案示例 1 分钟,大库可能
|
|
133
|
+
* 几分钟),由 pollUntilDone 内置 60 分钟上限兜底,远超正常场景。
|
|
134
|
+
*/
|
|
135
|
+
async function waitRecoveryDone(appId, target, rawTarget) {
|
|
136
|
+
try {
|
|
137
|
+
return await (0, poll_1.pollUntilDone)({
|
|
138
|
+
label: 'recovery apply',
|
|
139
|
+
spinnerLabel: `Restoring database (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
|
|
140
|
+
intervalMs: 2000,
|
|
141
|
+
fetch: () => api.db.getRecoveryStatus({ appId }),
|
|
142
|
+
isDone: (cur) => {
|
|
143
|
+
const status = (cur.status || '').toLowerCase();
|
|
144
|
+
if (status === 'success')
|
|
145
|
+
return { done: true, value: cur };
|
|
146
|
+
if (status === 'failed') {
|
|
147
|
+
throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? `recovery to ${target} failed`);
|
|
148
|
+
}
|
|
149
|
+
return { done: false };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
throw decorateRecoveryError(err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 触发 PITR 预览任务并轮询到终态:
|
|
159
|
+
* 1. POST /db/recovery dryRun=true → previewRequestId
|
|
160
|
+
* 2. GET /db/recovery/preview?previewRequestId 直到 previewStatus=success/failed
|
|
161
|
+
*
|
|
162
|
+
* 错误透传:业务错误(窗口超限 / 格式错)由 dataloom 直接 throw,CLI 在 catch 里
|
|
163
|
+
* 补 hint。failed 状态下 dataloom 已把 errorMessage 翻好,CLI 包成 k_dl_1300036
|
|
164
|
+
* 让 decorateRecoveryError 命中窗口超限 hint。
|
|
165
|
+
*/
|
|
166
|
+
async function runRecoveryPreview(appId, ts, rawTarget) {
|
|
167
|
+
let triggered;
|
|
168
|
+
try {
|
|
169
|
+
triggered = await api.db.recover({ appId, target: ts, dryRun: true });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
throw decorateRecoveryError(err);
|
|
173
|
+
}
|
|
174
|
+
if (triggered.previewRequestId === undefined || triggered.previewRequestId === '') {
|
|
175
|
+
throw new error_1.AppError('INTERNAL_DB_ERROR', 'recovery diff did not return previewRequestId');
|
|
176
|
+
}
|
|
177
|
+
const previewRequestId = triggered.previewRequestId;
|
|
178
|
+
try {
|
|
179
|
+
return await (0, poll_1.pollUntilDone)({
|
|
180
|
+
label: 'recovery preview',
|
|
181
|
+
spinnerLabel: `Previewing recovery impact (target: ${formatRecoveryTargetForDisplay(rawTarget)})`,
|
|
182
|
+
intervalMs: 1000,
|
|
183
|
+
fetch: () => api.db.getRecoveryPreview({ appId, previewRequestId }),
|
|
184
|
+
isDone: (cur) => {
|
|
185
|
+
// dataloom 内部 pgsvc 返回首字母大写枚举(Pending/Running/Success/Failed),
|
|
186
|
+
// 但 admin-inner thrift 契约里写的是小写。客户端做大小写归一兜底,避免依赖
|
|
187
|
+
// 上游 case 一致性导致死循环。
|
|
188
|
+
const status = cur.previewStatus.toLowerCase();
|
|
189
|
+
if (status === 'success')
|
|
190
|
+
return { done: true, value: cur };
|
|
191
|
+
if (status === 'failed') {
|
|
192
|
+
// 复用 k_dl_1300036(窗口超限 / 预览失败的统一对外码),让 decorateRecoveryError
|
|
193
|
+
// 命中后给 PITR 7 天窗口的 hint。errorMessage 优先用 dataloom 翻好的 PRD 文案。
|
|
194
|
+
throw new error_1.AppError('DB_API_k_dl_1300036', cur.errorMessage ?? 'recovery preview failed');
|
|
195
|
+
}
|
|
196
|
+
return { done: false };
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
throw decorateRecoveryError(err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── helpers ──
|
|
205
|
+
/**
|
|
206
|
+
* 把用户传入的 recovery target 解析成本地时区的 ISO 8601 字符串,
|
|
207
|
+
* 如 "2026-05-20T22:32:00+08:00"。无论用户传相对值(30m / 2h / 3d)还是绝对值,
|
|
208
|
+
* 都统一展示成"具体几点",让用户一眼看清楚"目标恢复点是我本地几点"——
|
|
209
|
+
* "30m ago"这种相对描述虽然原样直观,但跟 backend dataloom 收到的实际时间点没有
|
|
210
|
+
* 显式对照,遇到长 poll 时间过去后会让人怀疑"算的到底是哪个点"。
|
|
211
|
+
*
|
|
212
|
+
* 不用 UTC 'Z' 后缀:用户读时间不直观;带 +/-HH:MM offset 一眼即懂时差。
|
|
213
|
+
* 解析失败 → 原样返回兜底,避免 spinner 渲染崩。
|
|
214
|
+
*/
|
|
215
|
+
function formatRecoveryTargetForDisplay(rawInput) {
|
|
216
|
+
const trimmed = rawInput.trim();
|
|
217
|
+
let ms;
|
|
218
|
+
try {
|
|
219
|
+
ms = (0, time_1.parseTimeToMs)(trimmed);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return trimmed;
|
|
223
|
+
}
|
|
224
|
+
return formatIsoWithLocalOffset(ms);
|
|
225
|
+
}
|
|
226
|
+
function formatIsoWithLocalOffset(ms) {
|
|
227
|
+
const d = new Date(ms);
|
|
228
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
229
|
+
// getTimezoneOffset 返回本地相对 UTC 的差值(分钟)— UTC 在东八区是 -480
|
|
230
|
+
const offsetMinutes = -d.getTimezoneOffset();
|
|
231
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
232
|
+
const absOffset = Math.abs(offsetMinutes);
|
|
233
|
+
const tz = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
|
234
|
+
return (`${String(d.getFullYear())}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
235
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${tz}`);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 把用户传入的时间统一成 ISO 8601 UTC 字符串发给 dataloom。
|
|
239
|
+
*
|
|
240
|
+
* 复用 utils/time.ts::parseTimeToMs(跟 file ls / db changelog / db audit list 同一套
|
|
241
|
+
* 解析器),支持四种形态:
|
|
242
|
+
* - 相对时间:30s / 5m / 2h / 3d / 1w(从 now 往前推)
|
|
243
|
+
* - 仅日期: 2026-04-15 → 本地时区当日 00:00:00
|
|
244
|
+
* - 本地 datetime: 2026-04-15T10:00:00 → 本地时区
|
|
245
|
+
* - ISO 8601 with TZ: 2026-04-15T10:00:00Z 或 2026-04-15T10:00:00+08:00
|
|
246
|
+
*
|
|
247
|
+
* 不接受松散 / 区域性格式(YYYY/MM/DD、'April 1 2026' 等)——parseTimeToMs 内部
|
|
248
|
+
* 用严格 regex 把这些拒掉,避免之前用 new Date(input) 兜底把脏值送到后端的坑。
|
|
249
|
+
*/
|
|
250
|
+
function normalizeTimestamp(input) {
|
|
251
|
+
if (input === '') {
|
|
252
|
+
throw new error_1.AppError('ARGS_INVALID', 'target timestamp is required', {
|
|
253
|
+
next_actions: ['Usage: miaoda db recovery diff|apply <timestamp>'],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return new Date((0, time_1.parseTimeToMs)(input)).toISOString();
|
|
257
|
+
}
|
|
258
|
+
// PRD diff 输出,三套:
|
|
259
|
+
//
|
|
260
|
+
// TTY pretty(缩进 prose、带 Unicode 箭头、表名按列对齐):
|
|
261
|
+
// Recovery preview (→ 2026-04-15T10:00:00Z):
|
|
262
|
+
//
|
|
263
|
+
// tables affected: 2
|
|
264
|
+
// users: +3 rows, -1 row
|
|
265
|
+
// orders: table will be restored
|
|
266
|
+
//
|
|
267
|
+
// estimated time: ~30s
|
|
268
|
+
//
|
|
269
|
+
// non-TTY pretty(管道友好,扁平 TSV、ASCII 箭头、snake_case key、estimated_time 不带 ~/s):
|
|
270
|
+
// Recovery preview (-> 2026-04-15T10:00:00Z):
|
|
271
|
+
// tables_affected\t2
|
|
272
|
+
// users\t+3 rows, -1 row
|
|
273
|
+
// orders\ttable will be restored
|
|
274
|
+
// estimated_time\t30
|
|
275
|
+
//
|
|
276
|
+
// --json:标准 envelope,字段名固定 snake_case。
|
|
277
|
+
function renderDiff(target, preview) {
|
|
278
|
+
const changes = preview.changes ?? [];
|
|
279
|
+
const tablesAffected = preview.tablesAffected ?? changes.length;
|
|
280
|
+
const estimated = preview.estimatedSeconds;
|
|
281
|
+
if ((0, output_1.isJsonMode)()) {
|
|
282
|
+
(0, output_1.emitOk)((0, output_1.snakeCaseKeys)({
|
|
283
|
+
target,
|
|
284
|
+
tablesAffected,
|
|
285
|
+
changes: changes.map((c) => ({
|
|
286
|
+
table: c.table,
|
|
287
|
+
inserted: c.inserted,
|
|
288
|
+
deleted: c.deleted,
|
|
289
|
+
action: c.action,
|
|
290
|
+
droppedAt: c.droppedAt,
|
|
291
|
+
})),
|
|
292
|
+
estimatedSeconds: estimated ?? 30,
|
|
293
|
+
}));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const tty = (0, render_1.isStdoutTty)();
|
|
297
|
+
if (tty) {
|
|
298
|
+
(0, output_1.emit)(renderDiffTty(target, changes, tablesAffected, estimated));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
(0, output_1.emit)(renderDiffPipe(target, changes, tablesAffected, estimated));
|
|
302
|
+
}
|
|
303
|
+
function renderDiffTty(target, changes, tablesAffected, estimated) {
|
|
304
|
+
const header = `Recovery preview (→ ${target}):`;
|
|
305
|
+
if (changes.length === 0) {
|
|
306
|
+
return `${header}\n\n No changes — database is already at this state.`;
|
|
307
|
+
}
|
|
308
|
+
// 对齐:所有表名右侧补到 max(len) + 1 个 ":" + 1 个空格再起描述。PRD 用例里
|
|
309
|
+
// "users: " / "orders: " 三/二空格其实就是按 6 个 char 列宽对齐出来的。
|
|
310
|
+
const maxName = Math.max(...changes.map((c) => c.table.length));
|
|
311
|
+
const lines = [header, '', ` tables affected: ${String(tablesAffected)}`];
|
|
312
|
+
for (const c of changes) {
|
|
313
|
+
const pad = ' '.repeat(Math.max(1, maxName - c.table.length + 2));
|
|
314
|
+
lines.push(` ${c.table}:${pad}${describeChange(c)}`);
|
|
315
|
+
}
|
|
316
|
+
// PRD 要求 estimated time 块固定出现(即便 dataloom 当前没填 EstimatedSeconds,
|
|
317
|
+
// CLI 也用 30s 兜底——PRD 示例展示的就是 30s,统一一个不至于误导的常量)。
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push(` estimated time: ~${String(estimated ?? 30)}s`);
|
|
320
|
+
return lines.join('\n');
|
|
321
|
+
}
|
|
322
|
+
function renderDiffPipe(target, changes, tablesAffected, estimated) {
|
|
323
|
+
// 管道场景:去掉缩进,TAB 分列,箭头降级为 ->,key 用 snake_case,
|
|
324
|
+
// estimated_time 只输出秒数(不带 ~/s),方便 awk/grep。
|
|
325
|
+
const header = `Recovery preview (-> ${target}):`;
|
|
326
|
+
if (changes.length === 0) {
|
|
327
|
+
return `${header}\nNo changes — database is already at this state.`;
|
|
328
|
+
}
|
|
329
|
+
const lines = [header, `tables_affected\t${String(tablesAffected)}`];
|
|
330
|
+
for (const c of changes) {
|
|
331
|
+
lines.push(`${c.table}\t${describeChange(c)}`);
|
|
332
|
+
}
|
|
333
|
+
// 与 TTY 一致,始终输出 estimated_time(默认 30),让 awk 解析时不用兜底。
|
|
334
|
+
lines.push(`estimated_time\t${String(estimated ?? 30)}`);
|
|
335
|
+
return lines.join('\n');
|
|
336
|
+
}
|
|
337
|
+
function describeChange(c) {
|
|
338
|
+
// dataloom 端 action 是 PRD 三态 + unavailable 边界:
|
|
339
|
+
// restore_table — schema diff 显示该表在目标时间点存在但当前没有
|
|
340
|
+
// drop_table — 该表当前有但目标时间点没有
|
|
341
|
+
// alter_table — 两侧都在但结构有差异(列 / 索引 / 关系等)
|
|
342
|
+
// unavailable — PITR diff 算不出来,droppedAt 字段复用透传 message
|
|
343
|
+
// 没 action 时是数据行数变化,走下面的 +N / -N 渲染。
|
|
344
|
+
if (c.action === 'restore_table') {
|
|
345
|
+
return 'table will be restored';
|
|
346
|
+
}
|
|
347
|
+
if (c.action === 'drop_table') {
|
|
348
|
+
return 'table will be dropped';
|
|
349
|
+
}
|
|
350
|
+
if (c.action === 'alter_table') {
|
|
351
|
+
return 'table will be altered';
|
|
352
|
+
}
|
|
353
|
+
if (c.action === 'unavailable') {
|
|
354
|
+
return `diff unavailable${c.droppedAt !== undefined && c.droppedAt !== '' ? `: ${c.droppedAt}` : ''}`;
|
|
355
|
+
}
|
|
356
|
+
// 数据变更:+N rows / -N rows
|
|
357
|
+
const parts = [];
|
|
358
|
+
if (c.inserted !== undefined && c.inserted !== 0)
|
|
359
|
+
parts.push(`+${String(c.inserted)} rows`);
|
|
360
|
+
if (c.deleted !== undefined && c.deleted !== 0) {
|
|
361
|
+
parts.push(`-${String(c.deleted)} ${c.deleted === 1 ? 'row' : 'rows'}`);
|
|
362
|
+
}
|
|
363
|
+
return parts.length === 0 ? 'no changes' : parts.join(', ');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* 解析 dataloom 嵌在 message 末尾的窗口边界,形如 `[2026-04-09T12:00:00Z ~ 2026-04-16T12:00:00Z]`。
|
|
367
|
+
* 命中返 `{start, end}`,否则 null。两侧时间字符串原样透传给 hint,不做归一化。
|
|
368
|
+
*/
|
|
369
|
+
function parseWindowBounds(message) {
|
|
370
|
+
const m = /\[([^[\]]+?)\s*~\s*([^[\]]+?)\]\s*$/.exec(message);
|
|
371
|
+
if (!m)
|
|
372
|
+
return null;
|
|
373
|
+
const start = m[1].trim();
|
|
374
|
+
const end = m[2].trim();
|
|
375
|
+
if (start === '' || end === '')
|
|
376
|
+
return null;
|
|
377
|
+
return { start, end };
|
|
378
|
+
}
|
|
379
|
+
// decorateRecoveryError 给 recovery 路径上的几个错误码补 PRD 规定的 hint。
|
|
380
|
+
// dataloom 后端只返 message + code,hint 由 CLI 端按错误码映射;其它错误码原样透传。
|
|
381
|
+
function decorateRecoveryError(err) {
|
|
382
|
+
if (!(err instanceof error_1.AppError))
|
|
383
|
+
return err;
|
|
384
|
+
switch (err.code) {
|
|
385
|
+
case 'DB_API_k_dl_1300036': {
|
|
386
|
+
// 窗口超限:dataloom 端会把当前窗口边界拼成 `[<startISO> ~ <endISO>]` 嵌到 message
|
|
387
|
+
// 末尾,CLI 在这里提取出来按 PRD 格式 hint。提取失败时退回通用提示。
|
|
388
|
+
const bounds = parseWindowBounds(err.message);
|
|
389
|
+
if (bounds === null) {
|
|
390
|
+
return new error_1.AppError(err.code, err.message, {
|
|
391
|
+
next_actions: [
|
|
392
|
+
'PITR window is up to 7 days back, limited by your last `db migration apply` time.',
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// message 里把 [start ~ end] 段移除掉,避免重复
|
|
397
|
+
const cleanMsg = err.message.replace(/\s*\[[^\]]*\]\s*$/, '');
|
|
398
|
+
return new error_1.AppError(err.code, cleanMsg, {
|
|
399
|
+
next_actions: [
|
|
400
|
+
`Current recoverable window: ${bounds.start} ~ ${bounds.end}`,
|
|
401
|
+
`(limited by last migration apply at ${bounds.start})`,
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
case 'DB_API_k_dl_1300038':
|
|
406
|
+
// 时间格式错误:引导 ISO 8601
|
|
407
|
+
return new error_1.AppError(err.code, err.message, {
|
|
408
|
+
next_actions: ['Use ISO 8601 format, e.g. 2026-04-15 or 2026-04-15T10:00:00Z'],
|
|
409
|
+
});
|
|
410
|
+
default:
|
|
411
|
+
return err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -46,7 +46,7 @@ async function handleDbSchemaList(opts) {
|
|
|
46
46
|
const appId = opts.appId;
|
|
47
47
|
const resp = await api.db.getSchema({
|
|
48
48
|
appId,
|
|
49
|
-
format:
|
|
49
|
+
format: 'schema',
|
|
50
50
|
includeStats: true,
|
|
51
51
|
dbBranch: opts.env,
|
|
52
52
|
});
|
|
@@ -56,24 +56,24 @@ async function handleDbSchemaList(opts) {
|
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
58
|
if (tables.length === 0) {
|
|
59
|
-
(0, output_1.emit)(
|
|
59
|
+
(0, output_1.emit)('No tables found.');
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
const tty = (0, render_1.isStdoutTty)();
|
|
63
63
|
// PRD 对齐:TTY 表头用 `size`(友好格式),non-TTY 用 `size_bytes`(原始整数)。
|
|
64
64
|
// updated_at 暂时不展示——PG pg_catalog 不存真实表时间,详见 renderDetail 注释。
|
|
65
65
|
const headers = [
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
tty ?
|
|
70
|
-
|
|
66
|
+
'name',
|
|
67
|
+
'description',
|
|
68
|
+
'estimated_row_count',
|
|
69
|
+
tty ? 'size' : 'size_bytes',
|
|
70
|
+
'columns',
|
|
71
71
|
];
|
|
72
72
|
const rows = tables.map((t) => [
|
|
73
73
|
t.name,
|
|
74
|
-
t.description ??
|
|
75
|
-
t.estimated_row_count === null ?
|
|
76
|
-
t.size_bytes === null ?
|
|
74
|
+
t.description ?? '—',
|
|
75
|
+
t.estimated_row_count === null ? '—' : String(t.estimated_row_count),
|
|
76
|
+
t.size_bytes === null ? '—' : tty ? (0, render_1.formatSize)(t.size_bytes) : String(t.size_bytes),
|
|
77
77
|
String(t.columns),
|
|
78
78
|
]);
|
|
79
79
|
(0, output_1.emit)(tty ? (0, render_1.renderAlignedTable)(headers, rows) : (0, render_1.renderTsv)(headers, rows));
|
|
@@ -87,13 +87,13 @@ async function handleDbSchemaGet(table, opts) {
|
|
|
87
87
|
// non-TTY 或 --ddl:直接取完整 DDL 输出
|
|
88
88
|
const ddlResp = await api.db.getSchema({
|
|
89
89
|
appId,
|
|
90
|
-
format:
|
|
90
|
+
format: 'ddl',
|
|
91
91
|
tableNames: table,
|
|
92
92
|
dbBranch: opts.env,
|
|
93
93
|
});
|
|
94
94
|
const sql = ddlResp.ddl?.[table];
|
|
95
95
|
if (!sql) {
|
|
96
|
-
throw new error_1.AppError(
|
|
96
|
+
throw new error_1.AppError('TABLE_NOT_FOUND', `Table '${table}' does not exist`, {
|
|
97
97
|
next_actions: [
|
|
98
98
|
`Did you mean another table? Run "miaoda db schema list" to see all tables.`,
|
|
99
99
|
],
|
|
@@ -105,14 +105,14 @@ async function handleDbSchemaGet(table, opts) {
|
|
|
105
105
|
// TTY / JSON:取结构化
|
|
106
106
|
const resp = await api.db.getSchema({
|
|
107
107
|
appId,
|
|
108
|
-
format:
|
|
108
|
+
format: 'schema',
|
|
109
109
|
tableNames: table,
|
|
110
110
|
includeStats: true,
|
|
111
111
|
dbBranch: opts.env,
|
|
112
112
|
});
|
|
113
113
|
const detail = (0, index_1.pickTableDetail)(resp.schema, table);
|
|
114
114
|
if (!detail) {
|
|
115
|
-
throw new error_1.AppError(
|
|
115
|
+
throw new error_1.AppError('TABLE_NOT_FOUND', `Table '${table}' does not exist`, {
|
|
116
116
|
next_actions: [`Did you mean another table? Run "miaoda db schema list" to see all tables.`],
|
|
117
117
|
});
|
|
118
118
|
}
|
|
@@ -123,54 +123,54 @@ async function handleDbSchemaGet(table, opts) {
|
|
|
123
123
|
(0, output_1.emit)(renderDetail(detail, tty));
|
|
124
124
|
}
|
|
125
125
|
function renderDetail(d, tty) {
|
|
126
|
-
const systemFields = d.columns.filter((c) => c.name.startsWith(
|
|
127
|
-
const userFields = d.columns.filter((c) => !c.name.startsWith(
|
|
126
|
+
const systemFields = d.columns.filter((c) => c.name.startsWith('_'));
|
|
127
|
+
const userFields = d.columns.filter((c) => !c.name.startsWith('_'));
|
|
128
128
|
// header 布局:Name / Description / Columns(含"+ N system") / Estimated Rows / Size。
|
|
129
129
|
// 不展示 Created / Updated:PG pg_catalog 不存表创建时间,dataloom 用 OID
|
|
130
130
|
// 构造的伪时间戳(baseTime=2020-01-01 + OID 秒偏移),仅保排序意义、绝对值
|
|
131
131
|
// 误导性强,先去掉。后续如果接 ddl_change_log 取真实时间再加回。
|
|
132
132
|
const header = [
|
|
133
133
|
// 表名做 cyan 强调(spec:spec 里的"命令名/表名"类强调值都走 highlight)
|
|
134
|
-
[
|
|
135
|
-
[
|
|
134
|
+
['Name', tty ? colors_1.c.highlight(d.name) : d.name],
|
|
135
|
+
['Description', d.description ?? '—'],
|
|
136
136
|
[
|
|
137
|
-
|
|
137
|
+
'Columns',
|
|
138
138
|
systemFields.length > 0
|
|
139
139
|
? `${String(userFields.length)} (+ ${String(systemFields.length)} system)`
|
|
140
140
|
: String(userFields.length),
|
|
141
141
|
],
|
|
142
|
-
[
|
|
143
|
-
[
|
|
142
|
+
['Estimated Rows', d.estimated_row_count === null ? '—' : String(d.estimated_row_count)],
|
|
143
|
+
['Size', d.size_bytes === null ? '—' : (0, render_1.formatSize)(d.size_bytes)],
|
|
144
144
|
];
|
|
145
|
-
const colHeaders = [
|
|
145
|
+
const colHeaders = ['column', 'type', 'nullable', 'default', 'comment'];
|
|
146
146
|
const colRows = userFields.map((c) => [
|
|
147
147
|
c.name,
|
|
148
148
|
c.type,
|
|
149
|
-
c.nullable ?
|
|
150
|
-
c.default ??
|
|
151
|
-
c.comment ??
|
|
149
|
+
c.nullable ? 'yes' : 'no',
|
|
150
|
+
c.default ?? '—',
|
|
151
|
+
c.comment ?? '—',
|
|
152
152
|
]);
|
|
153
153
|
const parts = [];
|
|
154
154
|
parts.push((0, render_1.renderKeyValue)(header, tty));
|
|
155
|
-
parts.push(
|
|
155
|
+
parts.push('');
|
|
156
156
|
parts.push(tty ? (0, render_1.renderAlignedTable)(colHeaders, colRows) : (0, render_1.renderTsv)(colHeaders, colRows));
|
|
157
157
|
// Constraints 段(PRIMARY KEY / UNIQUE):表级约束独立成段,与普通索引分离展示
|
|
158
158
|
if (d.constraints.length > 0) {
|
|
159
|
-
parts.push(
|
|
160
|
-
parts.push(tty ?
|
|
159
|
+
parts.push('');
|
|
160
|
+
parts.push(tty ? ' Constraints:' : 'Constraints:');
|
|
161
161
|
for (const c of d.constraints) {
|
|
162
|
-
const line = `${c.type} (${c.columns.join(
|
|
162
|
+
const line = `${c.type} (${c.columns.join(', ')})`;
|
|
163
163
|
parts.push(tty ? ` ${line}` : line);
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
// Indexes 段:普通索引,格式 "<name> ON <col1, col2> USING <method>"
|
|
167
167
|
if (d.indexes.length > 0) {
|
|
168
|
-
parts.push(
|
|
169
|
-
parts.push(tty ?
|
|
168
|
+
parts.push('');
|
|
169
|
+
parts.push(tty ? ' Indexes:' : 'Indexes:');
|
|
170
170
|
for (const idx of d.indexes) {
|
|
171
|
-
const line = `${idx.name} ON ${idx.columns.join(
|
|
171
|
+
const line = `${idx.name} ON ${idx.columns.join(', ')} USING ${idx.method}`;
|
|
172
172
|
parts.push(tty ? ` ${line}` : line);
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
-
return parts.join(
|
|
175
|
+
return parts.join('\n');
|
|
176
176
|
}
|