dsh-db-tool 1.6.0 → 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.
@@ -262,6 +262,54 @@ async function route(req, res, service, path, q, opts) {
262
262
  const b = await readBody(req);
263
263
  return await sendMaybeConfirm(res, service.runScript(projectOfBody(b), str(b['connId']), str(b['code']), optStr(b['challengeId'])));
264
264
  }
265
+ // 数据传输(并行任务):start 需危险确认(challenge 往返);GET :taskId 轮询进度
266
+ //(快照 JSON-safe,bigint 键已转字符串);POST :taskId/cancel 取消
267
+ if (path === '/api/transfer/start' && req.method === 'POST') {
268
+ const b = await readBody(req);
269
+ return await sendMaybeConfirm(res, service.transferStart(projectOfBody(b), {
270
+ sourceConnId: str(b['sourceConnId']),
271
+ targetConnId: str(b['targetConnId']),
272
+ tables: Array.isArray(b['tables']) ? b['tables'].map(String) : [],
273
+ writeMode: b['writeMode'],
274
+ batchSize: num(b['batchSize']),
275
+ tableConcurrency: num(b['tableConcurrency']),
276
+ shardConcurrency: num(b['shardConcurrency']),
277
+ overwriteStructure: b['overwriteStructure'] === true,
278
+ sourceDatabase: optStr(b['sourceDatabase']),
279
+ sourceSchema: optStr(b['sourceSchema']),
280
+ targetDatabase: optStr(b['targetDatabase']),
281
+ targetSchema: optStr(b['targetSchema']),
282
+ }, optStr(b['challengeId'])));
283
+ }
284
+ // 传输历史:终态持久化(任何时候可查已传输/失败结果),支持按项目清除防积累
285
+ // 传输日志:全事件行(start/表完成/进度/终态),日志标签页 5s 轮询实时查看
286
+ if (path === '/api/transfer-log' && req.method === 'GET') {
287
+ const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
288
+ return sendOk(res, service.transferLogTail(projectOf(q), limit));
289
+ }
290
+ // 实时进度:运行中任务快照(含百分比所需行数),前端 2s 轮询
291
+ if (path === '/api/transfer/live' && req.method === 'GET') {
292
+ return sendOk(res, service.transferLive(projectOf(q)));
293
+ }
294
+ if (path === '/api/transfer-history' && req.method === 'GET') {
295
+ const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
296
+ return sendOk(res, service.transferHistory(projectOf(q), limit));
297
+ }
298
+ if (path === '/api/transfer-history/clear' && req.method === 'POST') {
299
+ return sendOk(res, service.transferHistoryClear());
300
+ }
301
+ const transferMatch = /^\/api\/transfer\/([^/]+)(\/cancel)?$/.exec(path);
302
+ if (transferMatch) {
303
+ const id = decodeURIComponent(transferMatch[1] ?? '');
304
+ if (transferMatch[2] === '/cancel' && req.method === 'POST') {
305
+ const b = await readBody(req);
306
+ return sendOk(res, await service.transferCancel(projectOfBody(b), id));
307
+ }
308
+ if (req.method === 'GET') {
309
+ // 进度快照含表清单/行数/错误详情:与 cancel 同样要求项目授权(query 带 project)
310
+ return sendOk(res, await service.transferProgress(projectOf(q), id));
311
+ }
312
+ }
265
313
  // 控制台事务会话(粘性连接):begin 发 token;exec 语义等同 execute(可能
266
314
  // NEEDS_CONFIRMATION);commit/rollback 提交/回滚后销毁会话。trust 与 body
267
315
  // 校验复用入口层既有链路。
@@ -433,6 +481,11 @@ function parseSsl(v) {
433
481
  function optStr(v) {
434
482
  return typeof v === 'string' && v !== '' ? v : undefined;
435
483
  }
484
+ /** 可选正整数字段:undefined/非法值返回 undefined(服务层兜底默认) */
485
+ function num(v) {
486
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
487
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
488
+ }
436
489
  function projectOf(q) {
437
490
  return q.has('project') ? (q.get('project') ?? undefined) : undefined;
438
491
  }
package/dist/manager.js CHANGED
@@ -4,6 +4,7 @@ import { ChallengeStore, classifyStatement, sqlHead, SQL_READ_HEADS, } from './g
4
4
  import { mergeUrlCredentials, normalizeProjectKey, DECRYPT_FAIL_MESSAGE, } from './store/index.js';
5
5
  import { runScriptInChild } from './script/runner.js';
6
6
  import { decryptPayload, encryptPayload } from './store/export-crypto.js';
7
+ import { startTransferTask } from './transfer.js';
7
8
  /* ---------------- 错误与结果类型 ---------------- */
8
9
  /** 携带机器可读 code 的业务错误(HTTP 层直接映射 {ok:false,error,code}) */
9
10
  export class DbToolError extends Error {
@@ -28,6 +29,12 @@ function assertNonEmpty(v, label) {
28
29
  }
29
30
  /** 单连接并发控制台事务会话上限(每会话独占驱动池 + checkout 连接,防连接耗尽) */
30
31
  const MAX_CONSOLE_SESSIONS_PER_CONN = 4;
32
+ /** 全局并发传输任务上限(每任务独立 reader/writer 连接,防连接耗尽) */
33
+ const MAX_TRANSFER_TASKS = 8;
34
+ /** 终态快照保留窗口:前端轮询间隔 800ms + 慢网络余量;窗口后定时清除(不无限滞留) */
35
+ const TRANSFER_SNAPSHOT_TTL_MS = 60_000;
36
+ /** 单任务表清单上限:statement 进 challenge 响应与审计(表名全量 join),防兆级放大 */
37
+ const MAX_TRANSFER_TABLES = 1000;
31
38
  /* ---------------- 服务核心 ---------------- */
32
39
  export class DbToolService {
33
40
  store;
@@ -64,9 +71,11 @@ export class DbToolService {
64
71
  if (!meta)
65
72
  throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
66
73
  // url/凭证/ssl 变更后旧适配器仍持旧连接串,必须作废重建;
67
- // 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL)
74
+ // 控制台事务会话的专用适配器不在缓存里,单独回收(否则旧凭据最长存活到空闲 TTL);
75
+ // 传输任务持独立连接,同样回收(凭证变更后不得按旧凭据继续读写)
68
76
  void this.dropAdapters(id);
69
77
  void this.dropConsoleSessions(id);
78
+ this.cancelTransferTasksForConn(id);
70
79
  this.audit('', id, 'update_connection', id, 'none', false, true);
71
80
  return meta;
72
81
  }
@@ -76,6 +85,7 @@ export class DbToolService {
76
85
  throw new DbToolError('NOT_FOUND', `连接不存在: ${id}`);
77
86
  void this.dropAdapters(id);
78
87
  void this.dropConsoleSessions(id);
88
+ this.cancelTransferTasksForConn(id);
79
89
  this.audit('', id, 'remove_connection', id, 'none', false, true);
80
90
  return ok;
81
91
  }
@@ -221,6 +231,7 @@ export class DbToolService {
221
231
  this.store.grants.revoke(key, connId);
222
232
  void this.dropAdapters(connId);
223
233
  void this.dropConsoleSessions(connId);
234
+ this.cancelTransferTasksForConn(connId); // 撤销后任务立即止损(否则写端全速跑完,管理员无系统内手段)
224
235
  this.audit(key, connId, 'revoke', connId, 'none', false, true);
225
236
  }
226
237
  /* -- 浏览(ro 即可) -- */
@@ -712,6 +723,177 @@ export class DbToolService {
712
723
  }
713
724
  }
714
725
  }
726
+ /* -- 数据传输(并行任务) -- */
727
+ /** 运行中的传输任务(taskId → 条目);终态后保留一个轮询窗口再清除(见 finished 回调) */
728
+ transferTasks = new Map();
729
+ async transferStart(projectPath, input, challengeId) {
730
+ const tables = input.tables;
731
+ if (!Array.isArray(tables) || tables.length === 0) {
732
+ throw new DbToolError('INVALID_ARGUMENT', 'tables 必须是非空字符串数组');
733
+ }
734
+ // 编排层按表名反查进度条目:重复名会让两遍传输共享同一进度对象且第二条永久 pending
735
+ if (new Set(tables).size !== tables.length) {
736
+ throw new DbToolError('INVALID_ARGUMENT', 'tables 存在重复表名,请去重后重试');
737
+ }
738
+ if (tables.length > MAX_TRANSFER_TABLES) {
739
+ throw new DbToolError('INVALID_ARGUMENT', `单任务表数超上限(${tables.length} > ${MAX_TRANSFER_TABLES}),请分批传输`);
740
+ }
741
+ const writeMode = input.writeMode ?? 'insert';
742
+ if (writeMode !== 'insert' && writeMode !== 'ignore' && writeMode !== 'replace' && writeMode !== 'truncate') {
743
+ throw new DbToolError('INVALID_ARGUMENT', `writeMode 非法: ${String(writeMode)}(insert/ignore/replace/truncate)`);
744
+ }
745
+ // 源连接只读授权即可(传输读端自建专用连接);目标连接需写
746
+ const { key: srcKey } = await this.authorize(projectPath, input.sourceConnId, false);
747
+ const { key: dstKey } = await this.authorize(projectPath, input.targetConnId, true);
748
+ const source = this.requireConn(input.sourceConnId);
749
+ const target = this.requireConn(input.targetConnId);
750
+ // 定位:optStr 语义(存在则 trim 非空才收,否则忽略);缺省 = 连接默认上下文
751
+ const optLoc = (v) => {
752
+ const s = typeof v === 'string' ? v.trim() : '';
753
+ return s !== '' ? s : undefined;
754
+ };
755
+ const srcDb = optLoc(input.sourceDatabase);
756
+ const srcSchema = optLoc(input.sourceSchema);
757
+ const dstDb = optLoc(input.targetDatabase);
758
+ const dstSchema = optLoc(input.targetSchema);
759
+ // 定位进 statement(start/finish/cancel 三链审计与 challenge 绑定共用一条),
760
+ // 首次生成与带 challengeId 重放同 input 必同值;schema 附着于库(前端联动恒先选库)
761
+ const locSeg = (db, schema) => db ? (schema ? `${db}.${schema}` : db) : '';
762
+ const locPart = [
763
+ srcDb ? ` 源=${locSeg(srcDb, srcSchema)}` : '',
764
+ dstDb ? ` 目标=${locSeg(dstDb, dstSchema)}` : '',
765
+ ].join('');
766
+ const statement = `数据传输 ${source.meta.kind}(${source.meta.id}) → ${target.meta.kind}(${target.meta.id}) 表[${tables.join(', ')}] 模式=${writeMode}${locPart}`;
767
+ // 危险操作确认(批量建表+写入,对齐 runGuarded 的 danger 分支语义)
768
+ const scope = { connId: input.targetConnId, projectKey: dstKey };
769
+ if (!challengeId) {
770
+ const { id: cid } = this.challenges.create(statement, scope);
771
+ this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', false, false, 'NEEDS_CONFIRMATION');
772
+ return {
773
+ needConfirmation: true,
774
+ challengeId: cid,
775
+ statement,
776
+ danger: 'danger',
777
+ reason: writeMode === 'truncate'
778
+ ? `【危险】将清空目标库 ${target.meta.kind}(${target.meta.id}) 中 ${tables.length} 张表的全部已有数据,随后重新写入,清空不可恢复,需要确认`
779
+ : `将向目标库 ${target.meta.kind}(${target.meta.id}) 建表并批量写入 ${tables.length} 张表,需要确认`,
780
+ };
781
+ }
782
+ if (!this.challenges.consume(challengeId, statement, scope)) {
783
+ this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', false, false, 'INVALID_CHALLENGE');
784
+ throw new DbToolError('INVALID_CHALLENGE', '确认凭据无效(不存在、已使用、已过期或语句已变更),请重新发起');
785
+ }
786
+ // —— 同步段:预算检查 + 注册之间零 await,杜绝并发 start 双过检查(TOCTOU)——
787
+ this.sweepTransferTasks();
788
+ const running = [...this.transferTasks.values()].filter((e) => e.task.snapshot().status === 'running');
789
+ if (running.length >= MAX_TRANSFER_TASKS) {
790
+ throw new DbToolError('INVALID_ARGUMENT', `全局并发传输任务已达上限(${MAX_TRANSFER_TASKS}),请等待既有任务完成`);
791
+ }
792
+ if (running.some((e) => e.targetConnId === input.targetConnId)) {
793
+ throw new DbToolError('INVALID_ARGUMENT', '该目标连接已有传输任务在执行(同目标并发写入会互踩),请等待完成或先取消');
794
+ }
795
+ const task = startTransferTask(source, target, {
796
+ tables,
797
+ writeMode,
798
+ ...(input.batchSize !== undefined ? { batchSize: input.batchSize } : {}),
799
+ ...(input.tableConcurrency !== undefined ? { tableConcurrency: input.tableConcurrency } : {}),
800
+ ...(input.shardConcurrency !== undefined ? { shardConcurrency: input.shardConcurrency } : {}),
801
+ ...(input.overwriteStructure === true ? { overwriteStructure: true } : {}),
802
+ ...(srcDb !== undefined || srcSchema !== undefined ? { sourceDatabase: srcDb, sourceSchema: srcSchema } : {}),
803
+ ...(dstDb !== undefined || dstSchema !== undefined ? { targetDatabase: dstDb, targetSchema: dstSchema } : {}),
804
+ // 传输日志:任务开始/表完成/进度节流/终态逐行落 <我的文档>/DSH/transfer-log.jsonl
805
+ onLog: (ev) => {
806
+ this.store.transferLog.append({
807
+ type: ev.type,
808
+ taskId: task.id,
809
+ projectPathKey: dstKey,
810
+ statement,
811
+ ...(ev.status !== undefined ? { status: ev.status } : {}),
812
+ ...(ev.table !== undefined ? { table: ev.table } : {}),
813
+ ...(ev.rows !== undefined ? { rows: ev.rows } : {}),
814
+ ...(ev.totalRows !== undefined ? { totalRows: ev.totalRows } : {}),
815
+ ...(ev.tables !== undefined ? { tables: ev.tables } : {}),
816
+ ...(ev.failures !== undefined ? { failures: ev.failures } : {}),
817
+ });
818
+ },
819
+ });
820
+ this.transferTasks.set(task.id, { task, sourceConnId: input.sourceConnId, targetConnId: input.targetConnId, projectKey: dstKey, statement });
821
+ this.audit(dstKey, input.targetConnId, 'transfer', statement, 'danger', true, true);
822
+ void task.finished.then((s) => {
823
+ const failed = s.failures.length > 0;
824
+ this.audit(dstKey, input.targetConnId, 'transfer_finish', statement, 'danger', true, !failed, failed ? `${s.failures.length} 个分片/表失败` : undefined);
825
+ // 终态不无限滞留 Map:保留一个轮询窗口(前端 800ms 轮询 + 慢网络余量)供拉到
826
+ // 终态快照,之后定时清除;start 时的 sweepTransferTasks 兜底清同一批条目
827
+ setTimeout(() => {
828
+ this.transferTasks.delete(task.id);
829
+ }, TRANSFER_SNAPSHOT_TTL_MS);
830
+ });
831
+ return { taskId: task.id };
832
+ }
833
+ async transferProgress(projectPath, taskId) {
834
+ const entry = this.transferTasks.get(taskId);
835
+ if (!entry)
836
+ throw new DbToolError('NOT_FOUND', `传输任务不存在或已结束: ${taskId}`);
837
+ // 快照含表清单/行数/错误详情,非公开信息:与数据面接口一致要求项目授权,
838
+ // 且必须与发起项目同 key(taskId 可枚举,不能凭 id 越项目读取)
839
+ const { key } = await this.authorize(projectPath, entry.targetConnId, false);
840
+ if (key !== entry.projectKey) {
841
+ throw new DbToolError('UNAUTHORIZED_PROJECT', `传输任务不属于该项目: ${taskId}`);
842
+ }
843
+ return entry.task.snapshot();
844
+ }
845
+ async transferCancel(projectPath, taskId) {
846
+ const entry = this.transferTasks.get(taskId);
847
+ if (!entry)
848
+ throw new DbToolError('NOT_FOUND', `传输任务不存在或已结束: ${taskId}`);
849
+ // 取消需对目标连接有写授权(能发起即能取消,授权语义对齐)
850
+ await this.authorize(projectPath, entry.targetConnId, true);
851
+ entry.task.cancel();
852
+ // 取消是影响目标库状态的操作:与 start/finish 同链审计(ok 以 cancelled 语义恒真)
853
+ this.audit(entry.projectKey, entry.targetConnId, 'transfer_cancel', entry.statement, 'danger', true, true);
854
+ return { cancelled: true };
855
+ }
856
+ /** 授权/连接变更时回收该连接参与的传输任务(对齐控制台会话的对称回收;cancel 为协作式,检查点退出) */
857
+ cancelTransferTasksForConn(connId) {
858
+ for (const entry of this.transferTasks.values()) {
859
+ if (entry.sourceConnId === connId || entry.targetConnId === connId)
860
+ entry.task.cancel();
861
+ }
862
+ }
863
+ /** 传输历史:最近 n 条任务终态(可选按项目过滤);对齐审计查询的公开语义 */
864
+ transferHistory(projectPath, limit = 50) {
865
+ const n = Math.min(Math.max(1, Math.floor(limit) || 50), 200);
866
+ return projectPath && projectPath.trim() !== ''
867
+ ? this.store.transferLog.tail(n, normalizeProjectKey(projectPath), 'finish')
868
+ : this.store.transferLog.tail(n, undefined, 'finish');
869
+ }
870
+ /** 实时进度:全部运行中任务的快照(可选按项目过滤),供「实时进度」子标签轮询 */
871
+ transferLive(projectPath) {
872
+ const key = projectPath && projectPath.trim() !== '' ? normalizeProjectKey(projectPath) : undefined;
873
+ return [...this.transferTasks.entries()]
874
+ .filter(([, e]) => e.task.snapshot().status === 'running' && (!key || e.projectKey === key))
875
+ .map(([taskId, e]) => ({ taskId, snapshot: e.task.snapshot() }));
876
+ }
877
+ /** 传输日志:最近 n 条全事件行(start/表完成/进度/终态),供日志标签实时查看 */
878
+ transferLogTail(projectPath, limit = 200) {
879
+ const n = Math.min(Math.max(1, Math.floor(limit) || 200), 2000);
880
+ return projectPath && projectPath.trim() !== ''
881
+ ? this.store.transferLog.tail(n, normalizeProjectKey(projectPath))
882
+ : this.store.transferLog.tail(n);
883
+ }
884
+ /** 清除传输历史:全量清除(历史区与文件同步清空)。留审计。 */
885
+ transferHistoryClear() {
886
+ const removed = this.store.transferLog.clear();
887
+ this.audit('', '', 'transfer_history_clear', '清除全部传输历史', 'none', false, true);
888
+ return { removed };
889
+ }
890
+ /** 清理终态任务(start 时调用;快照仅在 running 期间可查) */
891
+ sweepTransferTasks() {
892
+ for (const [id, entry] of this.transferTasks) {
893
+ if (entry.task.snapshot().status !== 'running')
894
+ this.transferTasks.delete(id);
895
+ }
896
+ }
715
897
  adapterFor(connId, mode) {
716
898
  const cacheKey = `${connId}@mode=${mode}`;
717
899
  let p = this.adapterCache.get(cacheKey);
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
13
13
  import * as os from 'node:os';
14
14
  import * as path from 'node:path';
15
15
  import { AuditLog } from './audit.js';
16
+ import { TransferLog, transferLogDir } from './transfer-history.js';
16
17
  import { ConnectionStore } from './connections.js';
17
18
  import { GrantStore } from './grants.js';
18
19
  import { chmodBestEffort } from './io.js';
@@ -28,6 +29,7 @@ export class DbToolStore {
28
29
  secrets;
29
30
  grants;
30
31
  audit;
32
+ transferLog;
31
33
  constructor(homeDir) {
32
34
  // || 而非 ??: DSH_HOME=""(空串)视为未设置,避免产出相对路径 'db-tool/'
33
35
  const home = homeDir ?? (process.env['DSH_HOME'] || path.join(os.homedir(), '.dsh'));
@@ -37,6 +39,9 @@ export class DbToolStore {
37
39
  this.secrets = new SecretsBox(this.dir);
38
40
  this.grants = new GrantStore(this.dir);
39
41
  this.audit = new AuditLog(this.dir);
42
+ // 传输历史独立于 store:存到 <我的文档>/DSH/ 供用户直接查看;
43
+ // vitest 运行时仍走 temp home,避免测试写用户真实文档目录
44
+ this.transferLog = new TransferLog(process.env.VITEST === 'true' ? this.dir : transferLogDir());
40
45
  this.connections = new ConnectionStore(this.dir, this.secrets, this.grants);
41
46
  }
42
47
  }
@@ -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
+ }