dsh-db-tool 1.5.91 → 1.7.0

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,114 @@
1
+ /**
2
+ * transfer-log.jsonl:传输过程日志(追加式,逐行一条事件)。
3
+ * 事件类型:
4
+ * - start 任务开始(statement 含源→目标/定位/模式)
5
+ * - table 单表状态变化(开始传输 / 完成 / 失败,含已写入行数)
6
+ * - progress 任务进度节流行(运行中各表实时行数,默认 5s 一条)
7
+ * - finish 任务终态(全量表明细 + 失败清单;「传输历史」弹窗即取此类型)
8
+ * 追加写天然原子;损坏行跳过不影响其余。文件位于 <我的文档>/DSH/,用户可直接打开查看。
9
+ */
10
+ import * as childProcess from 'node:child_process';
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ let cachedDocsDir;
15
+ /** 检测「我的文档」真实位置(每台机器可能重定向到任意盘/目录) */
16
+ function detectDocumentsDir() {
17
+ if (cachedDocsDir)
18
+ return cachedDocsDir;
19
+ let dir = '';
20
+ // Windows 官方途径:Shell API GetFolderPath('MyDocuments')——自动处理文档重定向。
21
+ // 不用 reg.exe:其中文系统输出为 GBK,utf8 解码会把重定向目录名变成乱码(实测踩坑)
22
+ if (process.platform === 'win32') {
23
+ try {
24
+ const out = childProcess.execFileSync('powershell', ['-NoProfile', '-Command', "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; [Environment]::GetFolderPath('MyDocuments')"], { timeout: 15000, encoding: 'utf8' });
25
+ dir = out.trim();
26
+ }
27
+ catch {
28
+ // PowerShell 不可用:走候选路径回退
29
+ }
30
+ }
31
+ if (dir === '') {
32
+ const home = os.homedir();
33
+ const candidates = process.platform === 'win32'
34
+ ? [process.env.OneDrive ? path.join(process.env.OneDrive, 'Documents') : '', path.join(home, 'Documents')]
35
+ : [process.env.XDG_DOCUMENTS_DIR ?? '', path.join(home, 'Documents')];
36
+ dir = candidates.find((c) => c !== '' && fs.existsSync(c)) ?? path.join(home, 'Documents');
37
+ }
38
+ cachedDocsDir = dir;
39
+ return dir;
40
+ }
41
+ /** 传输日志存储目录:<我的文档>/DSH/;DSH_TRANSFER_DIR 环境变量可覆盖 */
42
+ export function transferLogDir() {
43
+ const override = process.env.DSH_TRANSFER_DIR;
44
+ if (override && override !== '') {
45
+ fs.mkdirSync(override, { recursive: true });
46
+ return override;
47
+ }
48
+ const dir = path.join(detectDocumentsDir(), 'DSH');
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ return dir;
51
+ }
52
+ export class TransferLog {
53
+ filePath;
54
+ /** JSONL 文件绝对路径(供「在本机查看」) */
55
+ get file() {
56
+ return this.filePath;
57
+ }
58
+ constructor(dir) {
59
+ this.filePath = path.join(dir, 'transfer-log.jsonl');
60
+ fs.mkdirSync(dir, { recursive: true }); // 目录不存在时 appendFileSync 会 ENOENT
61
+ }
62
+ /** 追加一条日志;ts 省略时自动填充当前 UTC 时间 */
63
+ append(entry) {
64
+ const full = { ...entry, ts: entry.ts ?? new Date().toISOString() };
65
+ fs.appendFileSync(this.filePath, JSON.stringify(full) + '\n', 'utf8');
66
+ return full;
67
+ }
68
+ /** 最近 n 条(时间正序);projectKey/type 给定时过滤。损坏行跳过 */
69
+ tail(n, projectKey, type) {
70
+ if (!fs.existsSync(this.filePath))
71
+ return [];
72
+ const lines = fs.readFileSync(this.filePath, 'utf8').split('\n');
73
+ const out = [];
74
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
75
+ const line = lines[i]?.trim();
76
+ if (!line)
77
+ continue;
78
+ try {
79
+ const e = JSON.parse(line);
80
+ if (projectKey && e.projectPathKey !== projectKey)
81
+ continue;
82
+ if (type && e.type !== type)
83
+ continue;
84
+ out.push(e);
85
+ }
86
+ catch {
87
+ // 跳过损坏行
88
+ }
89
+ }
90
+ return out.reverse();
91
+ }
92
+ /** 清除全部日志。返回删除条数 */
93
+ clear() {
94
+ if (!fs.existsSync(this.filePath))
95
+ return 0;
96
+ const lines = fs.readFileSync(this.filePath, 'utf8').split('\n');
97
+ const kept = [];
98
+ let removed = 0;
99
+ for (const line of lines) {
100
+ const t = line.trim();
101
+ if (!t)
102
+ continue;
103
+ try {
104
+ JSON.parse(t);
105
+ removed += 1;
106
+ }
107
+ catch {
108
+ kept.push(t); // 损坏行原样保留
109
+ }
110
+ }
111
+ fs.writeFileSync(this.filePath, kept.length > 0 ? kept.join('\n') + '\n' : '', 'utf8');
112
+ return removed;
113
+ }
114
+ }
@@ -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.5.91",
3
+ "version": "1.7.0",
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",