dsh-db-tool 1.6.0 → 1.7.1

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.
@@ -0,0 +1,287 @@
1
+ import { createTransferReader, createTransferWriter, } from './adapters/transfer/model.js';
2
+ // 默认值按各驱动官方批量实践取:oracledb executeMany 防 DPI-1015 需分批(批 ~1000)、
3
+ // mongo bulkWrite 由驱动按 maxWriteBatchSize 自动切批(客户端批大小不敏感)、
4
+ // mysql2/pg 逐行 execute+事务包裹(批大小只影响事务粒度)——批 1000 为全库安全交集
5
+ const DEFAULT_BATCH = 1000;
6
+ const DEFAULT_TABLE_CONCURRENCY = 4;
7
+ const DEFAULT_SHARD_CONCURRENCY = 4;
8
+ /** 单表分片数上限(切块探测查询随之有界) */
9
+ const MAX_SHARDS = 32;
10
+ /** 单批行数上限:readBatch 单批整批进内存(HTTP 可传任意大值打内存) */
11
+ const MAX_BATCH = 5000;
12
+ /** 并行度上限:每 worker 独立 reader/writer 连接池,无界并发会打爆两端(前端表单 max 同值);
13
+ * ponytail: 连接预算 ≈ tableConcurrency × 目标端单池 max(pg 4/mysql 10),目标库 max_connections 偏小时由用户自行调低 */
14
+ const MAX_TABLE_CONCURRENCY = 16;
15
+ const MAX_SHARD_CONCURRENCY = 32;
16
+ /** 失败分片重试次数 */
17
+ const SHARD_RETRIES = 1;
18
+ let seq = 0;
19
+ /** 目标端单写者(SQLite 同库并发写锁死)→ 表级/分片全部串行 */
20
+ function isSingleWriterTarget(kind) {
21
+ return kind === 'sqlite';
22
+ }
23
+ /** 源端读走专用单连接(oralike)→ 表内分片并行不可用 */
24
+ function supportsShardRead(kind) {
25
+ return kind !== 'oracle' && kind !== 'dmdb';
26
+ }
27
+ export function startTransferTask(source, target, opts) {
28
+ const id = `t${Date.now().toString(36)}-${++seq}`;
29
+ const batchSize = Math.min(MAX_BATCH, Math.max(1, Math.floor(opts.batchSize ?? DEFAULT_BATCH)));
30
+ // 目标端单写者(SQLite 同库多连接并发写锁死)→ 表级并行钳 1;
31
+ // 表内分片仍可并行:分片共享同一 writer,其事务块为同步原子段,写入天然串行化
32
+ const tableConcurrency = isSingleWriterTarget(target.meta.kind)
33
+ ? 1
34
+ : Math.min(MAX_TABLE_CONCURRENCY, Math.max(1, Math.floor(opts.tableConcurrency ?? DEFAULT_TABLE_CONCURRENCY)));
35
+ const shardConcurrency = Math.min(MAX_SHARD_CONCURRENCY, Math.max(1, Math.floor(opts.shardConcurrency ?? DEFAULT_SHARD_CONCURRENCY)));
36
+ // 定位组装(全缺省 = undefined,读写端走现状路径);定位仅影响工厂入口,
37
+ // 分片/进度/快照编排不感知
38
+ const srcLoc = (opts.sourceDatabase ?? opts.sourceSchema)
39
+ ? { database: opts.sourceDatabase, schema: opts.sourceSchema }
40
+ : undefined;
41
+ const dstLoc = (opts.targetDatabase ?? opts.targetSchema)
42
+ ? { database: opts.targetDatabase, schema: opts.targetSchema }
43
+ : undefined;
44
+ const tables = opts.tables.map((name) => ({
45
+ name,
46
+ status: 'pending',
47
+ rows: 0,
48
+ totalRows: null,
49
+ shardsTotal: 0,
50
+ shardsDone: 0,
51
+ shardsFailed: 0,
52
+ }));
53
+ const failures = [];
54
+ let status = 'running';
55
+ let cancelled = false;
56
+ let resolveFinished;
57
+ const finished = new Promise((resolve) => {
58
+ resolveFinished = resolve;
59
+ });
60
+ const snapshot = () => ({
61
+ status,
62
+ tables: tables.map((t) => ({ ...t })),
63
+ failures: failures.map((f) => ({ ...f })),
64
+ });
65
+ const emit = (ev) => {
66
+ if (!opts.onLog)
67
+ return;
68
+ opts.onLog({ taskId: id, ...ev });
69
+ };
70
+ const finish = () => {
71
+ if (status === 'running')
72
+ status = cancelled ? 'cancelled' : failures.length > 0 ? 'failed' : 'done';
73
+ emit({ type: 'finish', status, tables: tables.map((t) => ({ ...t })), failures: failures.map((f) => ({ ...f })) });
74
+ resolveFinished?.(snapshot());
75
+ };
76
+ /** 分片切块:按 batchSize 采样边界键 → [lo, hi) 区间清单(探测查询 ≤ 分片数) */
77
+ async function planShards(reader, table, column, totalRows) {
78
+ const shardCount = Math.min(Math.max(1, Math.ceil(totalRows / batchSize)), MAX_SHARDS);
79
+ if (shardCount <= 1 || !reader.keyAt)
80
+ return [{ lo: null, hi: null, index: 0 }];
81
+ const bounds = [];
82
+ let prev = null;
83
+ for (let i = 1; i < shardCount; i++) {
84
+ if (cancelled)
85
+ break;
86
+ const key = await reader.keyAt(table, column, i * batchSize);
87
+ if (key === null)
88
+ break; // 边界越界(实际行数少于估计):提前收口
89
+ if (prev !== null && String(key) === String(prev))
90
+ continue; // 键重复(同值多行):跳过空区间
91
+ bounds.push({ lo: prev, hi: key, index: bounds.length });
92
+ prev = key;
93
+ }
94
+ bounds.push({ lo: bounds.length > 0 ? prev : null, hi: null, index: bounds.length });
95
+ return bounds;
96
+ }
97
+ async function runShard(reader, writer, t, column, shard, prog) {
98
+ const keyIdx = Math.max(0, t.columns.findIndex((c) => c.name === column));
99
+ const attempt = async () => {
100
+ let lo = shard.lo;
101
+ let loInclusive = true;
102
+ for (;;) {
103
+ if (cancelled)
104
+ return false; // 取消中断:与「完整跑完」区分(表级 status 依赖此判定)
105
+ const rows = await reader.readBatch(t.name, { mode: 'range', column, lo, loInclusive, hi: shard.hi }, batchSize);
106
+ if (rows.length === 0)
107
+ return true;
108
+ // 两步累加:`+= await` 的读改写跨 await,分片并发共享 prog 时会丢其他分片的增量
109
+ const written = await writer.writeBatch(t, rows);
110
+ prog.rows += written;
111
+ if (rows.length < batchSize)
112
+ return true;
113
+ const nextLo = rows[rows.length - 1]?.[keyIdx] ?? null;
114
+ // 守恒断言:批间链式(k > lo)下 lastKey 不前进 = 结构漂移致列错位,恒取同一批 → 死循环
115
+ if (!loInclusive && lo !== null && nextLo !== null && String(nextLo) === String(lo)) {
116
+ throw new Error(`keyset 分片无进展(键 ${String(lo)}),已中止:源表结构可能在传输期间被变更`);
117
+ }
118
+ lo = nextLo;
119
+ loInclusive = false; // 批间链式:k > lastKey 且 k < hi
120
+ }
121
+ };
122
+ try {
123
+ return await attempt();
124
+ }
125
+ catch (e) {
126
+ if (cancelled)
127
+ throw e;
128
+ return await attempt(); // 分片级重试 1 次(幂等性依赖 writeMode=ignore/replace)
129
+ }
130
+ }
131
+ async function runTable(name, prog) {
132
+ // reader/writer 创建也在 try 内:单表连接失败只毁该表(进 failures),
133
+ // 不冒泡终止整个任务;writer 失败时已打开的 reader 由 finally 关闭
134
+ let reader;
135
+ let writer;
136
+ try {
137
+ prog.status = 'running';
138
+ emit({ type: 'table', status: 'running', table: name, rows: 0, totalRows: null });
139
+ emit({ type: 'table', status: 'running', table: name, rows: 0, totalRows: null });
140
+ const r = await createTransferReader(source, srcLoc);
141
+ reader = r; // 立即回填外层引用:writer 创建抛错时 finally 仍能关闭已打开的 reader
142
+ const w = await createTransferWriter(target, opts.writeMode, dstLoc);
143
+ writer = w;
144
+ const t = await r.describe(name);
145
+ prog.totalRows = await r.count(name);
146
+ // 先查目标表是否存在——有无表都不该「建表失败」:
147
+ // 无表 → 建表再写;有表 → 按写入模式处理(结构冲突可开覆盖表结构选项)
148
+ const exists = await w.tableExists(name);
149
+ if (exists && opts.overwriteStructure) {
150
+ // 覆盖表结构:删除重建(危险选项;新表结构与源一致,无需清空)
151
+ await w.dropTable(name);
152
+ await w.createTable(t);
153
+ }
154
+ else if (exists) {
155
+ if (opts.writeMode === 'truncate' && w.beforeLoad) {
156
+ await w.beforeLoad(t); // 清空旧数据再写
157
+ }
158
+ else if (opts.writeMode === 'insert') {
159
+ throw new Error(`目标表已存在: ${name}(insert 不覆盖已有数据,请改用 ignore/replace/truncate 或开启覆盖表结构)`);
160
+ }
161
+ // ignore/replace:写入语句自带冲突处理,直接写
162
+ }
163
+ else {
164
+ await w.createTable(t);
165
+ }
166
+ const keyType = await r.shardKey(name);
167
+ const keyCol = keyType ? (t.columns.find((c) => c.primaryKey) ?? t.columns[0]) : undefined;
168
+ const shardable = keyType !== null &&
169
+ keyCol !== undefined &&
170
+ shardConcurrency > 1 &&
171
+ supportsShardRead(source.meta.kind) &&
172
+ r.keyAt !== undefined &&
173
+ (prog.totalRows ?? 0) > batchSize * 2;
174
+ if (shardable && keyCol) {
175
+ const shards = await planShards(r, name, keyCol.name, prog.totalRows ?? 0);
176
+ prog.shardsTotal = shards.length;
177
+ const workers = Array.from({ length: Math.min(shardConcurrency, shards.length) }, async () => {
178
+ for (;;) {
179
+ const shard = shards.shift();
180
+ if (shard === undefined || cancelled)
181
+ return;
182
+ try {
183
+ // 返回值区分「完整跑完」与「取消中断」:后者不计入 shardsDone(表级不得标 done)
184
+ if (await runShard(r, w, t, keyCol.name, shard, prog))
185
+ prog.shardsDone += 1;
186
+ }
187
+ catch (e) {
188
+ prog.shardsFailed += 1;
189
+ failures.push({
190
+ table: name,
191
+ shard: shard.index,
192
+ error: e instanceof Error ? e.message : String(e),
193
+ lastKey: shard.lo === null ? undefined : String(shard.lo),
194
+ });
195
+ }
196
+ }
197
+ });
198
+ await Promise.all(workers);
199
+ }
200
+ else {
201
+ // 顺序链:单分片 range 全序(含 keyset 链语义);无分片键走 offset
202
+ prog.shardsTotal = 1;
203
+ try {
204
+ const complete = keyType !== null && keyCol
205
+ ? await runShard(r, w, t, keyCol.name, { lo: null, hi: null }, prog)
206
+ : await runOffset(r, w, t, prog);
207
+ if (complete)
208
+ prog.shardsDone += 1;
209
+ }
210
+ catch (e) {
211
+ prog.shardsFailed += 1;
212
+ failures.push({ table: name, shard: -1, error: e instanceof Error ? e.message : String(e) });
213
+ }
214
+ }
215
+ // 表级终态:分片未跑完(取消时 worker 提前退出)不得标 done——否则被误读为「已完整传输」
216
+ const complete = prog.shardsDone + prog.shardsFailed >= prog.shardsTotal;
217
+ prog.status = complete
218
+ ? (prog.shardsFailed > 0 ? 'failed' : 'done')
219
+ : 'cancelled';
220
+ emit({ type: 'table', status: prog.status, table: name, rows: prog.rows, totalRows: prog.totalRows });
221
+ }
222
+ catch (e) {
223
+ prog.status = 'failed';
224
+ prog.error = e instanceof Error ? e.message : String(e);
225
+ failures.push({ table: name, shard: -1, error: prog.error });
226
+ }
227
+ finally {
228
+ await reader?.close().catch(() => { });
229
+ await writer?.close().catch(() => { });
230
+ }
231
+ }
232
+ /** 无分片键降级:offset 全序(sqlite 无主键表不用 rowid——评审已证伪该路径) */
233
+ async function runOffset(reader, writer, t, prog) {
234
+ for (let offset = 0;; offset += batchSize) {
235
+ if (cancelled)
236
+ return false; // 取消中断:与「完整跑完」区分(表级 status 依赖此判定)
237
+ const rows = await reader.readBatch(t.name, { mode: 'offset', offset }, batchSize);
238
+ if (rows.length === 0)
239
+ return true;
240
+ const written = await writer.writeBatch(t, rows);
241
+ prog.rows += written;
242
+ if (rows.length < batchSize)
243
+ return true;
244
+ }
245
+ }
246
+ // 主流程:表级 worker 池(表间并行;表内分片并行在 runTable 内部)
247
+ void Promise.resolve().then(async () => {
248
+ const queue = [...opts.tables];
249
+ const workers = Array.from({ length: Math.min(tableConcurrency, Math.max(queue.length, 1)) }, async () => {
250
+ for (;;) {
251
+ const name = queue.shift();
252
+ if (name === undefined || cancelled)
253
+ return;
254
+ const prog = tables.find((t) => t.name === name);
255
+ if (prog === undefined)
256
+ continue;
257
+ await runTable(name, prog);
258
+ }
259
+ });
260
+ try {
261
+ await Promise.all(workers);
262
+ finish();
263
+ }
264
+ catch (e) {
265
+ // 兜底(runTable 理论上全捕获):终态前先等残余 worker 收尾(writer close 在其
266
+ // finally),否则快照已终态而其他表的写入仍在向目标库进行——同目标互斥按 status
267
+ // 判定,会让新任务与残余写入并发
268
+ cancelled = true;
269
+ await Promise.allSettled(workers);
270
+ status = 'failed';
271
+ failures.push({ table: '*', shard: -1, error: e instanceof Error ? e.message : String(e) });
272
+ finish();
273
+ }
274
+ }).catch((e) => {
275
+ status = 'failed';
276
+ failures.push({ table: '*', shard: -1, error: e instanceof Error ? e.message : String(e) });
277
+ finish();
278
+ });
279
+ return {
280
+ id,
281
+ snapshot,
282
+ cancel: () => {
283
+ cancelled = true;
284
+ },
285
+ finished,
286
+ };
287
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,172 +0,0 @@
1
- # 连接管理「导出 / 导入」方案
2
-
3
- > 状态:已实现并验收(全仓 `tsc --noEmit` 0 报错;全量 `npm test` 564/564 通过;`npm run build` 成功)
4
- > 版本:dsh-db-tool ≥ 1.5.91
5
- > 范围:仅侧边栏「连接管理」面板;模型工具(LLM)不暴露。
6
- > 技术依据:Node.js crypto 官方文档 + MDN Web Docs(经 context7 检索),逐条对照见 §3、§4.4。
7
-
8
- ## 1. 背景与目标
9
-
10
- dsh-db-tool 把连接配置拆两处持久化:
11
-
12
- - `connections.json` — 连接元数据(绝不含密码;密码段在 URL 中被替换为 `***`)
13
- - `secrets.json` — 连接级机密(完整 URL / 明文密码,权限 0600)
14
-
15
- 用户需要跨机器迁移连接:一键导出为**单个加密文件**,在另一台机器(或另一个 DSH 实例)用口令解密导入。
16
-
17
- ### 成功标准(验收已满足)
18
-
19
- - 导出文件用任意文本编辑器打开**无明文密码**(整个 payload 加密)。
20
- - 导出 → 删除全部连接 → 导入,round-trip 后 `testTarget` 可拼回真实 URL/密码,连接可 test 连通。
21
- - 重复导入幂等:已存在 id 全部计入 `skipped`,不覆盖。
22
- - 零新增 npm 依赖(全部走 Node stdlib + 各库官方驱动)。
23
-
24
- ### 明确不做(YAGNI)
25
-
26
- - 不含 `grants.json` / `audit.jsonl`:授权是「项目路径 ↔ 连接」的机器绑定关系,跨机迁移无意义;审计不可移植。
27
- - 不做选择性导出(勾选部分连接):需要时后续给 export API 加 `ids?: string[]` 即可,不预留抽象。
28
- - 不在 `handleToolAction`(模型工具)暴露导出:机密不应进入 LLM 上下文,仅 HTTP 面板可用。
29
-
30
- ## 2. 关键设计决策
31
-
32
- | 决策点 | 结论 | 依据 |
33
- |---|---|---|
34
- | 导出内容 | 含密码 | 目标是跨机器迁移,无密码无法做到导入即可用 |
35
- | 加密算法 | 用户口令 + scrypt 派生密钥 + AES-256-GCM | Node stdlib `crypto` 足够,不引第三方加密库(ponytail 阶梯:stdlib 优先) |
36
- | 冲突策略 | **skip**(不覆盖) | 用户明确选择;幂等可重入,重复导入安全 |
37
- | 错误反馈 | 口令错 / 密文篡改 / 格式坏**统一**报「口令错误或文件已损坏」 | 不向外泄露失败原因,防侧信道探测 |
38
- | AAD | 不使用 | 见 §3「AAD 裁决」 |
39
-
40
- ## 3. 加密格式(按 Node 官方文档校准)
41
-
42
- 信封结构(JSON,二进制段全 base64):
43
-
44
- ```json
45
- {
46
- "v": 1,
47
- "kdf": "scrypt",
48
- "salt": "<base64, 16 字节>",
49
- "iv": "<base64, 12 字节>",
50
- "tag": "<base64, 16 字节>",
51
- "data": "<base64 密文>"
52
- }
53
- ```
54
-
55
- ### 官方文档逐条对照
56
-
57
- | 参数 | 取值 | 官方依据(context7 → nodejs/node) |
58
- |---|---|---|
59
- | KDF | `scryptSync(passphrase, salt, 32)` | `crypto.scryptSync` 官方定位为 password-based KDF,"designed to be expensive computationally and memory-wise in order to make brute-force attacks unrewarding" |
60
- | scrypt cost/blockSize/parallelization | **默认值**(N=16384, r=8, p=1),maxmem 默认 32MB | 官方默认值即推荐值,不显式覆盖 |
61
- | salt | 随机 **16 字节**(`randomBytes(16)`) | 官方:"It is recommended that a salt is random and at least 16 bytes long"(NIST SP 800-132) |
62
- | 算法 | `aes-256-gcm` | GCM 同时提供机密性与完整性(auth tag 防篡改) |
63
- | IV | 随机 **12 字节**(`randomBytes(12)`) | OpenSSL 官方变更说明:"The IV length is by default 12 bytes (96 bits)",GCM 推荐 96-bit |
64
- | auth tag | **默认 16 字节**,不显式传 `authTagLength` | Node DEP0182 已废止短 tag(<16B)以对齐 NIST SP 800-38D;`createCipheriv` 文档明确 GCM 默认 16 字节 |
65
- | 解密顺序 | `setAuthTag(tag)` 必须在 `final()` 前 | 官方示例代码(`test-crypto-authenticated.js`):`decrypt.setAuthTag(...)` → `update` → `final` |
66
-
67
- ### AAD 裁决(不使用,理由记录)
68
-
69
- 官方示例用 `setAAD` 把密文绑定到外部上下文(用户 id、文件名等),防信封被整体搬到别的上下文冒充。本方案不加,理由:
70
-
71
- 1. 信封自含 `v/kdf/salt/iv/tag`,且**口令本身就是解密前提**——信封整体替换后在任何机器上都需要同一口令才能解,AAD 不增加实际安全边界。
72
- 2. 引入 AAD 需要持久化一个额外的上下文字段并保持两端同步,是真实复杂度(ponytail 阶梯:两个同级方案取边界更简单的)。
73
- 3. 文件本身就是交付物,不存在「信封被混进别的数据流」的攻击面。
74
-
75
- 解密任一环节失败(JSON 解析、结构校验、base64 长度校验、GCM tag 验证、明文 JSON 解析)统一抛 `Error('口令错误或文件已损坏')`(常量 `DECRYPT_FAIL_MESSAGE`)。
76
-
77
- 明文 payload 结构:
78
-
79
- ```json
80
- {
81
- "connections": [{ "id", "kind", "name?", "urlSafe?", "fields?", "ssl?" }],
82
- "secrets": { "<connId>": { "url?", "password?", "...其他机密键" } }
83
- }
84
- ```
85
-
86
- `secrets` 保留 `SecretEntry` 的扩展键(如 MongoDB 的额外凭证),不只挑 `url/password`。
87
-
88
- ## 4. 实现分层
89
-
90
- ### 4.1 存储层 — `lib/store/`
91
-
92
- **`export-crypto.ts`(新建,纯函数)**
93
-
94
- - `encryptPayload(plain: unknown, passphrase: string): string` — 非空口令断言 → scryptSync(pass, salt, 32) → AES-256-GCM → 信封 JSON。
95
- - `decryptPayload(encryptedJson: string, passphrase: string): unknown` — 结构校验 + iv/tag/salt 长度校验 + tag 验证 → 原始 payload;任一失败统一抛 `DECRYPT_FAIL_MESSAGE`。
96
- - `DECRYPT_FAIL_MESSAGE` 导出供 service 层复用。
97
-
98
- **`connections.ts`(修改)**
99
-
100
- - `exportBundle(): { connections: ConnRecord[]; secrets: Record<string, SecretEntry> }`
101
- 从 `load()` 取全部连接记录,逐条 `this.secrets.get(id)` 合并;无机密的连接不出现在 `secrets` 中。
102
- - `importBundle(bundle): { imported: number; skipped: string[]; errors: {id, message}[] }`
103
- 逐条走**既有** `this.create()`(机密拆分 / URL 脱敏逻辑全复用,不新造写入路径):`sec.url` 作完整 URL 传入、`sec.password` 经 `fields.password` 自动拆分。冲突(`连接已存在`)→ `skipped`;其他异常 → `errors` 续跑。create 后 `this.secrets.set(rec.id, sec)` 幂等合并写回,保留扩展机密键。
104
-
105
- **`index.ts`(修改)**:补导出 `encryptPayload / decryptPayload / DECRYPT_FAIL_MESSAGE` 及 `ConnRecord` 类型。
106
-
107
- ### 4.2 服务层 — `lib/manager.ts`(DbToolService)
108
-
109
- - `exportConnections(passphrase: string): string`
110
- 非空校验 → `store.exportBundle()` → `encryptPayload` → 审计 `export_connections`(statement 仅记「导出 N 个连接」,不落口令/密文)。
111
- - `importConnections(encryptedJson: string, passphrase: string)`
112
- 解密失败 + 结构校验失败(缺 `connections` 数组)统一转 `DbToolError('INVALID_ARGUMENT', DECRYPT_FAIL_MESSAGE)` → `store.importBundle` → 审计 `import_connections`(记 imported/skipped 数)。
113
-
114
- ### 4.3 HTTP 层 — `lib/http/index.ts`
115
-
116
- 两条新路由,**插在 `connMatch` 之前**(`/api/connections/export` 与 `/api/connections/import` 都是单层路径,放后面会被正则 `^/api/connections/([^/]+)` 当作连接 id 静默吞掉):
117
-
118
- | 路由 | Body | 成功 | 失败 |
119
- |---|---|---|---|
120
- | `POST /api/connections/export` | `{passphrase}` | `{ok:true, data:{json}}` | 缺/空 passphrase → 400 INVALID_ARGUMENT |
121
- | `POST /api/connections/import` | `{json, passphrase}` | `{ok:true, data:{imported, skipped[], errors[]}}` | 缺参 400;口令错走 `sendError` → 200 + `{ok:false, code:'INVALID_ARGUMENT'}`(与既有契约一致) |
122
-
123
- trust 校验(Host/Origin loopback)、2MB body 上限、`{ok,error,code}` 响应契约全部复用既有链路。
124
-
125
- ### 4.4 客户端 — `client/client.js`(ManageView)
126
-
127
- **i18n**:zh/en 各加 8 键(exportConns / importConns / passphrase / passphraseHint / exportDone / importResult / importDecryptFail / pickFile)。
128
-
129
- **`PassphraseDialog`**(局部组件,紧邻 `EMPTY_FORM`):复用 `dbt-overlay/dbt-dialog` 样式;键盘契约对齐 `DangerDialog`(Escape=取消、Tab 循环锁定),差异——初始焦点在口令输入框、确认按钮主色、Enter 提交、前端预校验 ≥6 位。ponytail:仅两处使用,未抽通用模块。
130
-
131
- **导出 / 导入文件 IO(按 MDN 官方文档校准)**:
132
-
133
- | 操作 | MDN 最佳实践 | 实现 |
134
- |---|---|---|
135
- | 下载 | `Blob` → `URL.createObjectURL` → `a.download` → click | `dsh-db-connections-YYYYMMDD.enc.json` |
136
- | 释放 object URL | 必须 `revokeObjectURL`,但**不能过早**(官方明示:立即 revoke 会让用户无法保存/打开) | `a.click()` 后 `setTimeout(revoke, 1000)`,与仓库内结果导出下载同一模式 |
137
- | 读取本地文件 | `FileReader.readAsText` + `onload/onerror` 双通道,先校验文件存在 | 读失败走通用错误通道 |
138
-
139
- **流程**:
140
- - 导出:点导出 → 口令窗 → `POST connections/export` → Blob 下载 → `dbt-msg` 显示 `exportDone`。
141
- - 导入:点导入 → 隐藏 `input[type=file accept=".json"]` 选文件 → FileReader 读文本 → 口令窗 → `POST connections/import` → `reload()` → `dbt-msg` 显示 `importResult`;`err.code === 'INVALID_ARGUMENT'` 时 `dbt-err` 显示 `importDecryptFail`。
142
-
143
- ## 5. 边界与失败模式
144
-
145
- | 场景 | 行为 |
146
- |---|---|
147
- | 口令为空 / 非字符串 | store 层抛 `Error('口令必须为非空字符串')`;HTTP 层 400 |
148
- | 密文被篡改 | GCM tag 校验失败 → 统一「口令错误或文件已损坏」(不泄露是哪一环) |
149
- | 导入文件非信封结构 / 明文缺 `connections` 数组 | 同上统一错误 |
150
- | 单条导入失败(kind 非法、secrets 写入 I/O 故障等) | 记入 `errors` 续跑,不整批回滚(与 skip 语义一致,幂等可重入) |
151
- | 连接无密码 | 不出现在 `secrets` 段;导入后无 `hasPassword` 指示 |
152
-
153
- ## 6. 测试
154
-
155
- - `tests/store/export-crypto.spec.ts`(5 例):round-trip(密文不含明文)、随机 salt/iv 同文异密、错口令统一错误、篡改 data/tag/结构统一错误、空/非字符串口令拒绝。
156
- - `tests/store/connections.spec.ts`(追加 4 例):exportBundle 含密码、importBundle skip 冲突不覆盖 + 重复导入幂等、单条异常入 errors 续跑、round-trip 后 testTarget 密码/URL 一致且 connections.json 无明文。
157
- - `tests/tool/manager.spec.ts`(追加 4 例):round-trip(导出→删除→导入恢复)、错口令 INVALID_ARGUMENT、空参数 INVALID_ARGUMENT、导出/导入审计断言(记条数、不落口令)。
158
- - `tests/http/api.spec.ts`(追加 3 例):happy path(HTTP 建连接→导出→全删→导入恢复,断言密文不含明文连接串)、缺/空 passphrase 400、坏密文导入 INVALID_ARGUMENT。
159
-
160
- 验收命令:`npm run typecheck && npm test && npm run build`,全部通过。
161
-
162
- ## 7. 实施过程(Agent Teams 并行)
163
-
164
- 任务拆为 3 个 teammate 并行,写范围互斥:
165
-
166
- | Teammate | 写范围 | 产出 |
167
- |---|---|---|
168
- | store-crypto | `lib/store/connections.ts`、`lib/store/export-crypto.ts` + 配套测试 | 存储层 + 加解密,21 测试绿 |
169
- | service-http | `lib/manager.ts`、`lib/http/index.ts` + 配套测试 | service + HTTP 路由,46 测试绿 |
170
- | client-ui | `client/client.js` | UI + i18n,98 client 测试无回归 |
171
-
172
- 契约先行冻结(store 方法签名、HTTP 端点、错误 code),三路按契约并行开发;store-crypto 完成后 service-http 的真实 store 联跑自然转绿。Lead 负责收尾 integrator(`lib/store/index.ts` 导出面)+ 全仓验收。