whistle.mockbubu 1.0.0-dev.4 → 2.0.0-beta.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,1368 @@
1
+ /**
2
+ * FileSystemStorage V3 - 基于文件系统的存储层
3
+ *
4
+ * 架构设计:
5
+ * - 每个组一个独立目录
6
+ * - 索引文件(index.json)用于快速列表查询
7
+ * - 文件数据按需加载,避免内存溢出
8
+ * - 支持流式导出/导入
9
+ *
10
+ * 目录结构:
11
+ * groups/
12
+ * ├── {groupId}/
13
+ * │ ├── group.json # 组元数据
14
+ * │ ├── index.json # 文件索引(轻量级)
15
+ * │ └── files/
16
+ * │ └── {fileId}/
17
+ * │ ├── file.json # 文件完整数据
18
+ * │ └── versions/ # 版本目录
19
+ * │ ├── v1.json
20
+ * │ └── v2.json
21
+ */
22
+
23
+ const fs = require('fs').promises
24
+ const path = require('path')
25
+
26
+ class FileSystemStorage {
27
+ constructor(baseDir) {
28
+ this.baseDir = baseDir
29
+ this.groupsDir = path.join(baseDir, 'groups')
30
+ this.currentGroupFile = path.join(baseDir, 'current-group.txt')
31
+ this.propertiesFile = path.join(baseDir, 'properties.json')
32
+ this.indexCache = new Map() // 索引缓存
33
+ this.maxCacheAge = 3000 // 缓存3秒(降低延迟,提高实时性)
34
+ this.propertiesCache = null // 通用属性缓存
35
+ this.indexLocks = new Map() // 索引写入锁(内存锁,同进程有效)
36
+ }
37
+
38
+ /**
39
+ * 初始化存储目录
40
+ */
41
+ async init() {
42
+ await fs.mkdir(this.groupsDir, { recursive: true })
43
+
44
+ // 确保默认组存在
45
+ const defaultGroupDir = path.join(this.groupsDir, 'default')
46
+ try {
47
+ await fs.access(defaultGroupDir)
48
+ } catch {
49
+ await this.createGroup({
50
+ id: 'default',
51
+ name: '默认组',
52
+ description: '系统默认分组',
53
+ isDefault: true,
54
+ })
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 生成文件ID(随机生成,确保唯一性)
60
+ * ⚠️ 重要设计原则:
61
+ * - fileId 是文件系统的唯一标识符,用于删除/编辑/查找文件
62
+ * - 每次创建新文件时生成新的 fileId(即使URL相同)
63
+ * - 重复检测通过文件名(URL)而不是 fileId
64
+ * - 删除后重新捕获同一URL会生成新的 fileId
65
+ *
66
+ * @returns {string} 12位随机ID
67
+ */
68
+ generateFileId() {
69
+ // 使用时间戳 + 随机数确保唯一性
70
+ const timestamp = Date.now().toString(36)
71
+ const random = Math.random().toString(36).substring(2, 10)
72
+ return `${timestamp}${random}`.substring(0, 12)
73
+ }
74
+
75
+ // ========================================
76
+ // 组管理
77
+ // ========================================
78
+
79
+ /**
80
+ * 列出所有组
81
+ */
82
+ async listGroups() {
83
+ const entries = await fs.readdir(this.groupsDir, { withFileTypes: true })
84
+ const groups = []
85
+
86
+ for (const entry of entries) {
87
+ if (entry.isDirectory()) {
88
+ try {
89
+ const groupData = await this.getGroup(entry.name)
90
+ groups.push(groupData)
91
+ } catch (err) {
92
+ console.error(`[storage-v3] 读取组失败: ${entry.name}`, err.message)
93
+ }
94
+ }
95
+ }
96
+
97
+ // 排序:默认组在前,其他组按创建时间升序(旧组在前,新组在后)
98
+ return groups.sort((a, b) => {
99
+ // 默认组始终排在第一位
100
+ if (a.isDefault) return -1
101
+ if (b.isDefault) return 1
102
+ // 其他组按创建时间升序排序
103
+ return (a.createTime || 0) - (b.createTime || 0)
104
+ })
105
+ }
106
+
107
+ /**
108
+ * 获取组信息
109
+ * 如果组文件不存在,自动创建默认组元数据
110
+ */
111
+ async getGroup(groupId) {
112
+ const groupFile = path.join(this.groupsDir, groupId, 'group.json')
113
+
114
+ try {
115
+ const content = await fs.readFile(groupFile, 'utf8')
116
+ return JSON.parse(content)
117
+ } catch (err) {
118
+ if (err.code === 'ENOENT') {
119
+ // 文件不存在,自动创建默认组元数据
120
+ console.log(`[storage-v3] 组元数据不存在,自动创建: ${groupId}`)
121
+
122
+ const defaultMeta = {
123
+ id: groupId,
124
+ name: groupId === 'default' ? '默认组' : groupId,
125
+ description: '',
126
+ isDefault: groupId === 'default',
127
+ fileCount: 0,
128
+ totalSize: 0,
129
+ createTime: Date.now(),
130
+ updateTime: Date.now(),
131
+ }
132
+
133
+ // 确保目录存在
134
+ const groupDir = path.join(this.groupsDir, groupId)
135
+ await fs.mkdir(groupDir, { recursive: true })
136
+
137
+ // 写入组元数据
138
+ await this._writeJSON(groupFile, defaultMeta)
139
+
140
+ return defaultMeta
141
+ }
142
+
143
+ // 其他错误继续抛出
144
+ console.error(`[storage-v3] 读取组元数据失败: ${groupId}`, err)
145
+ throw err
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 创建组
151
+ */
152
+ async createGroup(groupData) {
153
+ const { id, name, description = '', isDefault = false } = groupData
154
+
155
+ const groupDir = path.join(this.groupsDir, id)
156
+ const filesDir = path.join(groupDir, 'files')
157
+
158
+ // 创建目录结构
159
+ await fs.mkdir(filesDir, { recursive: true })
160
+
161
+ // 写入组元数据
162
+ const group = {
163
+ id,
164
+ name,
165
+ description,
166
+ isDefault,
167
+ fileCount: 0,
168
+ totalSize: 0,
169
+ createTime: Date.now(),
170
+ updateTime: Date.now(),
171
+ }
172
+
173
+ await this._writeJSON(path.join(groupDir, 'group.json'), group)
174
+
175
+ // 初始化索引文件
176
+ const index = {
177
+ version: '3.0.0',
178
+ updateTime: Date.now(),
179
+ files: [],
180
+ }
181
+
182
+ await this._writeJSON(path.join(groupDir, 'index.json'), index)
183
+
184
+ // 更新组元数据文件(_meta.json)
185
+ try {
186
+ const meta = await this.getGroupsMeta()
187
+ if (!meta.groups.find(g => g.id === id)) {
188
+ meta.groups.push(group)
189
+ await this.setGroupsMeta(meta)
190
+ }
191
+ } catch (err) {
192
+ console.error(`[storage-v3 ${new Date().toLocaleString('zh-CN', { hour12: false })}] 更新 _meta.json 失败:`, err.message)
193
+ }
194
+
195
+ return group
196
+ }
197
+
198
+ /**
199
+ * 更新组信息
200
+ */
201
+ async updateGroup(groupId, updates) {
202
+ const group = await this.getGroup(groupId)
203
+ const updated = {
204
+ ...group,
205
+ ...updates,
206
+ updateTime: Date.now(),
207
+ }
208
+
209
+ const groupFile = path.join(this.groupsDir, groupId, 'group.json')
210
+ await this._writeJSON(groupFile, updated)
211
+
212
+ return updated
213
+ }
214
+
215
+ /**
216
+ * 删除组(删除整个目录)
217
+ */
218
+ async deleteGroup(groupId) {
219
+ if (groupId === 'default') {
220
+ throw new Error('不能删除默认组')
221
+ }
222
+
223
+ const groupDir = path.join(this.groupsDir, groupId)
224
+ await fs.rm(groupDir, { recursive: true, force: true })
225
+
226
+ // 清除缓存
227
+ this.indexCache.delete(groupId)
228
+
229
+ // 同时更新组元数据,移除已删除的组
230
+ const meta = await this.getGroupsMeta()
231
+ meta.groups = meta.groups.filter(g => g.id !== groupId)
232
+ await this.setGroupsMeta(meta)
233
+ }
234
+
235
+ /**
236
+ * 获取组元数据(所有组列表 + 当前组ID)
237
+ * Phase 2: 从独立的 _meta.json 读取,兼容旧 properties 文件
238
+ */
239
+ async getGroupsMeta() {
240
+ const metaFile = path.join(this.groupsDir, '_meta.json')
241
+
242
+ try {
243
+ // 尝试从新位置读取
244
+ const content = await fs.readFile(metaFile, 'utf-8')
245
+ return JSON.parse(content)
246
+ } catch (err) {
247
+ // 如果文件不存在,尝试从 properties 迁移
248
+ if (err.code === 'ENOENT') {
249
+ console.log(`[storage-v3 ${new Date().toLocaleString('zh-CN', { hour12: false })}] _meta.json 不存在,尝试从 properties 迁移`)
250
+ return await this._migrateGroupsMetaFromProperties()
251
+ }
252
+ throw err
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 设置组元数据
258
+ * Phase 2: 写入独立的 _meta.json
259
+ */
260
+ async setGroupsMeta(meta) {
261
+ const metaFile = path.join(this.groupsDir, '_meta.json')
262
+ await this._writeJSON(metaFile, {
263
+ ...meta,
264
+ version: '3.0', // 架构版本号
265
+ updateTime: Date.now(),
266
+ })
267
+ }
268
+
269
+ /**
270
+ * 从 properties 文件迁移组元数据(内部方法)
271
+ * @private
272
+ */
273
+ async _migrateGroupsMetaFromProperties() {
274
+ // 从 storage-adapter 读取旧的 properties
275
+ const StorageAdapter = require('./storage-adapter')
276
+ const adapter = new StorageAdapter({ basePath: this.baseDir })
277
+
278
+ try {
279
+ const data = await adapter.getProperty('__groups__')
280
+ if (data && data.groups) {
281
+ console.log(`[storage-v3 ${new Date().toLocaleString('zh-CN', { hour12: false })}] 从 properties 迁移 ${data.groups.length} 个组`)
282
+
283
+ // 写入新位置
284
+ await this.setGroupsMeta({
285
+ groups: data.groups,
286
+ currentGroupId: data.currentGroupId || 'default',
287
+ })
288
+
289
+ return {
290
+ groups: data.groups,
291
+ currentGroupId: data.currentGroupId || 'default',
292
+ }
293
+ }
294
+ } catch (err) {
295
+ console.error(`[storage-v3 ${new Date().toLocaleString('zh-CN', { hour12: false })}] 从 properties 迁移失败:`, err.message)
296
+ }
297
+
298
+ // 如果迁移失败,返回默认值
299
+ const defaultMeta = {
300
+ groups: [{
301
+ id: 'default',
302
+ name: '默认组',
303
+ description: '系统默认分组',
304
+ createTime: Date.now(),
305
+ updateTime: Date.now(),
306
+ isDefault: true,
307
+ }],
308
+ currentGroupId: 'default',
309
+ }
310
+
311
+ await this.setGroupsMeta(defaultMeta)
312
+ return defaultMeta
313
+ }
314
+
315
+ /**
316
+ * 复制组的所有文件(V3架构专用)
317
+ * @param {string} sourceGroupId - 源组ID
318
+ * @param {string} targetGroupId - 目标组ID
319
+ */
320
+ async copyGroup(sourceGroupId, targetGroupId) {
321
+ console.log(`[storage-v3 2025/11/19 14:10:00] 开始复制组: ${sourceGroupId} -> ${targetGroupId}`)
322
+
323
+ // 1. 读取源组的索引
324
+ const sourceIndex = await this.getIndex(sourceGroupId)
325
+ console.log(`[storage-v3 2025/11/19 14:10:00] 源组文件数: ${sourceIndex.files.length}`)
326
+
327
+ // 2. 确保目标组目录存在
328
+ const targetGroupDir = path.join(this.groupsDir, targetGroupId)
329
+ const targetFilesDir = path.join(targetGroupDir, 'files')
330
+ await fs.mkdir(targetFilesDir, { recursive: true })
331
+
332
+ // 3. 复制所有文件
333
+ const copiedFiles = []
334
+ for (const fileEntry of sourceIndex.files) {
335
+ try {
336
+ const sourceFileDir = path.join(this.groupsDir, sourceGroupId, 'files', fileEntry.id)
337
+ const targetFileDir = path.join(targetFilesDir, fileEntry.id)
338
+
339
+ // 递归复制文件目录(包括file.json和versions/)
340
+ await this._copyDirectory(sourceFileDir, targetFileDir)
341
+
342
+ // 将文件条目添加到目标索引
343
+ copiedFiles.push({
344
+ ...fileEntry,
345
+ // 保持原有时间戳
346
+ })
347
+
348
+ console.log(`[storage-v3 2025/11/19 14:10:00] 已复制文件: ${fileEntry.url} (${fileEntry.id})`)
349
+ } catch (err) {
350
+ console.error(`[storage-v3 2025/11/19 14:10:00] 复制文件失败: ${fileEntry.id}`, err.message)
351
+ }
352
+ }
353
+
354
+ // 4. 创建目标组索引
355
+ const targetIndex = {
356
+ version: '3.0.0',
357
+ files: copiedFiles,
358
+ updateTime: Date.now(),
359
+ }
360
+
361
+ await this.updateIndex(targetGroupId, targetIndex)
362
+ console.log(`[storage-v3 2025/11/19 14:10:00] 复制完成,共 ${copiedFiles.length} 个文件`)
363
+
364
+ return copiedFiles.length
365
+ }
366
+
367
+ /**
368
+ * 递归复制目录
369
+ * @private
370
+ */
371
+ async _copyDirectory(source, target) {
372
+ await fs.mkdir(target, { recursive: true })
373
+
374
+ const entries = await fs.readdir(source, { withFileTypes: true })
375
+
376
+ for (const entry of entries) {
377
+ const sourcePath = path.join(source, entry.name)
378
+ const targetPath = path.join(target, entry.name)
379
+
380
+ if (entry.isDirectory()) {
381
+ await this._copyDirectory(sourcePath, targetPath)
382
+ } else {
383
+ await fs.copyFile(sourcePath, targetPath)
384
+ }
385
+ }
386
+ }
387
+
388
+ // ========================================
389
+ // 索引管理
390
+ // ========================================
391
+
392
+ /**
393
+ * 获取索引(带缓存)
394
+ * 如果索引文件不存在,自动创建空索引
395
+ */
396
+ async getIndex(groupId) {
397
+ // 检查缓存
398
+ const cached = this.indexCache.get(groupId)
399
+ if (cached && Date.now() - cached.time < this.maxCacheAge) {
400
+ return cached.data
401
+ }
402
+
403
+ // 读取索引文件
404
+ const indexFile = path.join(this.groupsDir, groupId, 'index.json')
405
+
406
+ try {
407
+ const content = await fs.readFile(indexFile, 'utf8')
408
+ const index = JSON.parse(content)
409
+
410
+ // 更新缓存
411
+ this.indexCache.set(groupId, { data: index, time: Date.now() })
412
+
413
+ return index
414
+ } catch (err) {
415
+ if (err.code === 'ENOENT') {
416
+ // 文件不存在,自动创建空索引
417
+ console.log(`[storage-v3] 索引文件不存在,自动创建空索引: ${groupId}`)
418
+
419
+ const emptyIndex = {
420
+ version: '3.0.0',
421
+ files: [],
422
+ updateTime: Date.now(),
423
+ }
424
+
425
+ // 确保目录存在
426
+ const groupDir = path.join(this.groupsDir, groupId)
427
+ await fs.mkdir(groupDir, { recursive: true })
428
+
429
+ // 写入空索引
430
+ await this._writeJSON(indexFile, emptyIndex)
431
+
432
+ // 更新缓存
433
+ this.indexCache.set(groupId, { data: emptyIndex, time: Date.now() })
434
+
435
+ return emptyIndex
436
+ }
437
+
438
+ // 其他错误继续抛出
439
+ console.error(`[storage-v3] 读取索引失败: ${groupId}`, err)
440
+ throw err
441
+ }
442
+ }
443
+
444
+ /**
445
+ * 更新索引
446
+ */
447
+ async updateIndex(groupId, indexData) {
448
+ const indexFile = path.join(this.groupsDir, groupId, 'index.json')
449
+ const updated = {
450
+ ...indexData,
451
+ updateTime: Date.now(),
452
+ }
453
+
454
+ await this._writeJSON(indexFile, updated)
455
+
456
+ // 清除缓存
457
+ this.indexCache.delete(groupId)
458
+ }
459
+
460
+ /**
461
+ * 使索引失效(强制下次重新读取)
462
+ */
463
+ invalidateIndex(groupId) {
464
+ this.indexCache.delete(groupId)
465
+ }
466
+
467
+ /**
468
+ * 重建索引(扫描 files 目录)
469
+ */
470
+ async rebuildIndex(groupId) {
471
+ console.log(`[storage-v3] 开始重建组 ${groupId} 的索引...`)
472
+
473
+ const filesDir = path.join(this.groupsDir, groupId, 'files')
474
+
475
+ try {
476
+ const fileDirs = await fs.readdir(filesDir)
477
+ const index = {
478
+ version: '3.0.0',
479
+ files: [],
480
+ updateTime: Date.now(),
481
+ }
482
+
483
+ for (const fileId of fileDirs) {
484
+ if (fileId.includes('.backup')) continue // 跳过备份文件
485
+
486
+ const fileJsonPath = path.join(filesDir, fileId, 'file.json')
487
+
488
+ try {
489
+ const fileData = await fs.readFile(fileJsonPath, 'utf8')
490
+ const file = JSON.parse(fileData)
491
+
492
+ // 添加到索引
493
+ index.files.push({
494
+ id: file.id,
495
+ url: file.url,
496
+ method: file.method || 'GET',
497
+ status: file.status || 200,
498
+ size: this._calculateSize(file),
499
+ mock: file.config?.mock || false,
500
+ locked: file.config?.locked || false,
501
+ mockVersion: file.config?.mockVersion || 'source',
502
+ versionCount: 0, // TODO: 扫描 versions 目录
503
+ createTime: file.createTime,
504
+ updateTime: file.updateTime,
505
+ })
506
+
507
+ console.log(`[storage-v3] ✓ 添加文件: ${file.url}`)
508
+ } catch (err) {
509
+ console.warn(`[storage-v3] ✗ 跳过损坏的文件: ${fileId}`, err.message)
510
+ }
511
+ }
512
+
513
+ // 写入索引
514
+ await this.updateIndex(groupId, index)
515
+ console.log(`[storage-v3] 索引重建完成,共 ${index.files.length} 个文件`)
516
+
517
+ return index
518
+ } catch (err) {
519
+ console.error('[storage-v3] 重建索引失败:', err.message)
520
+ throw err
521
+ }
522
+ }
523
+
524
+ // ========================================
525
+ // 文件操作
526
+ // ========================================
527
+
528
+ /**
529
+ * 列出文件(从索引)
530
+ * @param {string} groupId - 组ID
531
+ * @param {object} options - 选项
532
+ * @param {number} options.offset - 偏移量
533
+ * @param {number} options.limit - 限制数量
534
+ * @param {object} options.filter - 过滤条件
535
+ */
536
+ async listFiles(groupId, options = {}) {
537
+ const { offset = 0, limit, filter = {} } = options
538
+
539
+ const index = await this.getIndex(groupId)
540
+ let files = index.files
541
+
542
+ // 应用过滤器
543
+ if (Object.keys(filter).length > 0) {
544
+ files = files.filter(file => {
545
+ return Object.entries(filter).every(([key, value]) => file[key] === value)
546
+ })
547
+ }
548
+
549
+ // 应用分页
550
+ if (limit) {
551
+ files = files.slice(offset, offset + limit)
552
+ } else if (offset > 0) {
553
+ files = files.slice(offset)
554
+ }
555
+
556
+ return files
557
+ }
558
+
559
+ /**
560
+ * 获取文件完整数据
561
+ * 如果文件损坏,返回 null 而不是抛出错误
562
+ */
563
+ async getFile(groupId, fileId) {
564
+ const fileJsonPath = path.join(this.groupsDir, groupId, 'files', fileId, 'file.json')
565
+
566
+ try {
567
+ const content = await fs.readFile(fileJsonPath, 'utf8')
568
+ return JSON.parse(content)
569
+ } catch (err) {
570
+ if (err.code === 'ENOENT') {
571
+ // 文件不存在
572
+ return null
573
+ }
574
+
575
+ // JSON 解析错误 - 文件损坏
576
+ if (err instanceof SyntaxError) {
577
+ console.error(`[storage-v3] 文件JSON损坏,跳过: ${groupId}/${fileId}`, err.message)
578
+ return null
579
+ }
580
+
581
+ // 其他错误继续抛出
582
+ throw err
583
+ }
584
+ }
585
+
586
+ /**
587
+ * 创建文件
588
+ */
589
+ async createFile(groupId, fileId, fileData) {
590
+ // 验证数据
591
+ const validation = this._validateJSON(fileData)
592
+ if (!validation.valid) {
593
+ const error = new Error(`Cannot store invalid JSON data: ${validation.error}`)
594
+ console.error(`[storage-v3] 创建文件失败: ${groupId}/${fileId}`, error.message)
595
+ throw error
596
+ }
597
+
598
+ const fileDir = path.join(this.groupsDir, groupId, 'files', fileId)
599
+ const versionsDir = path.join(fileDir, 'versions')
600
+
601
+ // 创建目录
602
+ await fs.mkdir(versionsDir, { recursive: true })
603
+
604
+ // 确保 config 字段存在
605
+ const config = fileData.config || {
606
+ mock: false,
607
+ locked: false,
608
+ mockVersion: 'source',
609
+ mockTime: null,
610
+ }
611
+
612
+ // 写入文件数据(只保留 config 字段,不保留根级别的配置字段)
613
+ const file = {
614
+ id: fileId,
615
+ url: fileData.url,
616
+ method: fileData.method || 'GET',
617
+ status: fileData.status || 200,
618
+ date: fileData.date || Date.now(),
619
+ rule: fileData.rule || '',
620
+ ruleValue: fileData.ruleValue || '',
621
+ pattern: fileData.pattern || '',
622
+ config,
623
+ createTime: Date.now(),
624
+ updateTime: Date.now(),
625
+ }
626
+
627
+ await this._writeJSON(path.join(fileDir, 'file.json'), file)
628
+
629
+ // 解析 URL 增强字段
630
+ const urlFields = this._parseUrlFields(fileData.url)
631
+
632
+ // 更新索引(包含增强字段)
633
+ await this._addToIndex(groupId, {
634
+ id: fileId,
635
+ url: fileData.url,
636
+ method: fileData.method || 'GET',
637
+ status: fileData.status || 200,
638
+ size: this._calculateSize(file),
639
+ mock: config.mock,
640
+ locked: config.locked,
641
+ mockVersion: config.mockVersion,
642
+ versionCount: 0,
643
+ createTime: file.createTime,
644
+ updateTime: file.updateTime,
645
+ // ✨ Phase 3.1: 索引增强字段
646
+ domain: urlFields.domain,
647
+ pathname: urlFields.pathname,
648
+ urlHash: urlFields.urlHash,
649
+ })
650
+
651
+ // 更新组统计
652
+ await this._updateGroupStats(groupId)
653
+
654
+ return file
655
+ }
656
+
657
+ /**
658
+ * 更新文件
659
+ * 如果原文件损坏或不存在,将 updates 作为新文件创建
660
+ */
661
+ async updateFile(groupId, fileId, updates) {
662
+ const file = await this.getFile(groupId, fileId)
663
+
664
+ // 🔧 FIX: 过滤掉 updates 中值为 undefined 的字段
665
+ // 这样可以支持删除字段的操作
666
+ const filteredUpdates = {}
667
+ Object.keys(updates).forEach(key => {
668
+ if (updates[key] !== undefined) {
669
+ filteredUpdates[key] = updates[key]
670
+ }
671
+ })
672
+
673
+ let updated
674
+ if (!file) {
675
+ // 原文件不存在或损坏,使用 updates 作为新文件
676
+ console.log(`[storage-v3] 原文件不存在或损坏,创建新文件: ${groupId}/${fileId}`)
677
+
678
+ // 确保 config 字段存在
679
+ const config = filteredUpdates.config || {
680
+ mock: false,
681
+ locked: false,
682
+ mockVersion: 'source',
683
+ mockTime: null,
684
+ }
685
+
686
+ updated = {
687
+ id: fileId,
688
+ ...filteredUpdates,
689
+ config,
690
+ createTime: Date.now(),
691
+ updateTime: Date.now(),
692
+ }
693
+ } else {
694
+ // 正常更新:深度合并 config 字段
695
+ const mergedConfig = {
696
+ ...file.config,
697
+ ...filteredUpdates.config,
698
+ }
699
+
700
+ updated = {
701
+ ...file,
702
+ ...filteredUpdates,
703
+ config: mergedConfig,
704
+ updateTime: Date.now(),
705
+ }
706
+
707
+ // 🔧 FIX: 从合并结果中删除 updates 里标记为 undefined 的字段
708
+ Object.keys(updates).forEach(key => {
709
+ if (updates[key] === undefined) {
710
+ delete updated[key]
711
+ }
712
+ })
713
+
714
+ // 清理根级别的重复配置字段(如果存在)
715
+ delete updated.mock
716
+ delete updated.locked
717
+ delete updated.mockVersion
718
+ delete updated.mockTime
719
+ }
720
+
721
+ // 验证更新后的数据
722
+ const validation = this._validateJSON(updated)
723
+ if (!validation.valid) {
724
+ const error = new Error(`Cannot store invalid JSON data: ${validation.error}`)
725
+ console.error(`[storage-v3] 更新文件失败: ${groupId}/${fileId}`, error.message)
726
+ throw error
727
+ }
728
+
729
+ const fileJsonPath = path.join(this.groupsDir, groupId, 'files', fileId, 'file.json')
730
+ await this._writeJSON(fileJsonPath, updated)
731
+
732
+ // 解析 URL 增强字段(如果 URL 发生变化)
733
+ const urlFields = updated.url ? this._parseUrlFields(updated.url) : {}
734
+
735
+ // 更新索引(包含增强字段)
736
+ await this._updateInIndex(groupId, fileId, {
737
+ url: updated.url,
738
+ method: updated.method,
739
+ status: updated.status,
740
+ size: this._calculateSize(updated),
741
+ mock: updated.config?.mock,
742
+ locked: updated.config?.locked,
743
+ mockVersion: updated.config?.mockVersion,
744
+ updateTime: updated.updateTime,
745
+ // ✨ Phase 3.1: 索引增强字段
746
+ ...(updated.url && {
747
+ domain: urlFields.domain,
748
+ pathname: urlFields.pathname,
749
+ urlHash: urlFields.urlHash,
750
+ }),
751
+ })
752
+
753
+ return updated
754
+ }
755
+
756
+ /**
757
+ * 删除文件
758
+ */
759
+ async deleteFile(groupId, fileId) {
760
+ const logFile = '/tmp/mockbubu-delete.log'
761
+ const log = (msg) => {
762
+ const fs = require('fs')
763
+ fs.appendFileSync(logFile, `${new Date().toISOString()} ${msg}\n`)
764
+ }
765
+
766
+ log('[storage-v3] deleteFile 开始')
767
+ log(`[storage-v3] groupId: ${groupId}`)
768
+ log(`[storage-v3] fileId: ${fileId}`)
769
+
770
+ const fileDir = path.join(this.groupsDir, groupId, 'files', fileId)
771
+ log(`[storage-v3] 要删除的目录: ${fileDir}`)
772
+
773
+ try {
774
+ await fs.rm(fileDir, { recursive: true, force: true })
775
+ log('[storage-v3] 物理文件删除成功')
776
+ } catch (err) {
777
+ log(`[storage-v3] 物理文件删除失败: ${err.message}`)
778
+ throw err
779
+ }
780
+
781
+ // 从索引中移除
782
+ try {
783
+ await this._removeFromIndex(groupId, fileId)
784
+ log('[storage-v3] 已从索引中移除')
785
+ } catch (err) {
786
+ log(`[storage-v3] 从索引移除失败: ${err.message}`)
787
+ throw err
788
+ }
789
+
790
+ // 更新组统计
791
+ try {
792
+ await this._updateGroupStats(groupId)
793
+ log('[storage-v3] 组统计已更新')
794
+ } catch (err) {
795
+ log(`[storage-v3] 更新组统计失败: ${err.message}`)
796
+ throw err
797
+ }
798
+ }
799
+
800
+ // ========================================
801
+ // Phase 3.2: 批量操作支持
802
+ // ========================================
803
+
804
+ /**
805
+ * 批量删除文件
806
+ * @param {string} groupId - 组ID
807
+ * @param {Array<string>} fileIds - 文件ID数组
808
+ * @returns {Promise<{success: number, failed: Array}>} 删除结果统计
809
+ */
810
+ async batchDeleteFiles(groupId, fileIds) {
811
+ console.log(`[storage-v3] 🗑️ 批量删除开始: ${fileIds.length} 个文件`)
812
+
813
+ const results = {
814
+ success: 0,
815
+ failed: [],
816
+ }
817
+
818
+ // 并发删除文件(但保持索引操作的串行性)
819
+ await Promise.all(
820
+ fileIds.map(async fileId => {
821
+ try {
822
+ const fileDir = path.join(this.groupsDir, groupId, 'files', fileId)
823
+ await fs.rm(fileDir, { recursive: true, force: true })
824
+ results.success++
825
+ } catch (err) {
826
+ console.error(`[storage-v3] 删除文件失败: ${groupId}/${fileId}`, err.message)
827
+ results.failed.push({ fileId, error: err.message })
828
+ }
829
+ }),
830
+ )
831
+
832
+ // 批量从索引中移除(一次性更新索引)
833
+ try {
834
+ const release = await this._acquireIndexLock(groupId)
835
+ try {
836
+ this.invalidateIndex(groupId)
837
+ const index = await this.getIndex(groupId)
838
+
839
+ // 过滤掉已删除的文件
840
+ const initialCount = index.files.length
841
+ index.files = index.files.filter(f => !fileIds.includes(f.id))
842
+ const removedCount = initialCount - index.files.length
843
+
844
+ await this.updateIndex(groupId, index)
845
+ console.log(`[storage-v3] ✅ 从索引中移除 ${removedCount} 个文件`)
846
+ } finally {
847
+ this._releaseIndexLock(groupId, release)
848
+ }
849
+ } catch (err) {
850
+ console.error('[storage-v3] 批量更新索引失败:', err.message)
851
+ }
852
+
853
+ // 更新组统计
854
+ await this._updateGroupStats(groupId)
855
+
856
+ console.log(`[storage-v3] ✅ 批量删除完成: 成功 ${results.success}, 失败 ${results.failed.length}`)
857
+ return results
858
+ }
859
+
860
+ /**
861
+ * 批量更新文件(用于批量修改 config 等字段)
862
+ * @param {string} groupId - 组ID
863
+ * @param {Array<{fileId: string, updates: Object}>} updates - 更新数组
864
+ * @returns {Promise<{success: number, failed: Array}>} 更新结果统计
865
+ */
866
+ async batchUpdateFiles(groupId, updates) {
867
+ console.log(`[storage-v3] 📝 批量更新开始: ${updates.length} 个文件`)
868
+
869
+ const results = {
870
+ success: 0,
871
+ failed: [],
872
+ }
873
+
874
+ // 并发更新文件内容
875
+ await Promise.all(
876
+ updates.map(async ({ fileId, updates: fileUpdates }) => {
877
+ try {
878
+ // 读取现有文件
879
+ const file = await this.getFile(groupId, fileId)
880
+ if (!file) {
881
+ throw new Error('文件不存在')
882
+ }
883
+
884
+ // 合并更新
885
+ const updated = {
886
+ ...file,
887
+ ...fileUpdates,
888
+ config: {
889
+ ...file.config,
890
+ ...fileUpdates.config,
891
+ },
892
+ updateTime: Date.now(),
893
+ }
894
+
895
+ // 写入文件
896
+ const fileJsonPath = path.join(this.groupsDir, groupId, 'files', fileId, 'file.json')
897
+ await this._writeJSON(fileJsonPath, updated)
898
+
899
+ results.success++
900
+ } catch (err) {
901
+ console.error(`[storage-v3] 更新文件失败: ${groupId}/${fileId}`, err.message)
902
+ results.failed.push({ fileId, error: err.message })
903
+ }
904
+ }),
905
+ )
906
+
907
+ // 批量更新索引(一次性)
908
+ try {
909
+ const release = await this._acquireIndexLock(groupId)
910
+ try {
911
+ this.invalidateIndex(groupId)
912
+ const index = await this.getIndex(groupId)
913
+
914
+ // 更新索引中的相关字段
915
+ updates.forEach(({ fileId, updates: fileUpdates }) => {
916
+ const fileIndex = index.files.findIndex(f => f.id === fileId)
917
+ if (fileIndex !== -1) {
918
+ index.files[fileIndex] = {
919
+ ...index.files[fileIndex],
920
+ ...fileUpdates,
921
+ updateTime: Date.now(),
922
+ }
923
+ }
924
+ })
925
+
926
+ await this.updateIndex(groupId, index)
927
+ console.log('[storage-v3] ✅ 批量更新索引完成')
928
+ } finally {
929
+ this._releaseIndexLock(groupId, release)
930
+ }
931
+ } catch (err) {
932
+ console.error('[storage-v3] 批量更新索引失败:', err.message)
933
+ }
934
+
935
+ console.log(`[storage-v3] ✅ 批量更新完成: 成功 ${results.success}, 失败 ${results.failed.length}`)
936
+ return results
937
+ }
938
+
939
+ /**
940
+ * 文件是否存在
941
+ */
942
+ async fileExists(groupId, fileId) {
943
+ const fileJsonPath = path.join(this.groupsDir, groupId, 'files', fileId, 'file.json')
944
+ try {
945
+ await fs.access(fileJsonPath)
946
+ return true
947
+ } catch {
948
+ return false
949
+ }
950
+ }
951
+
952
+ // ========================================
953
+ // 版本管理
954
+ // ========================================
955
+
956
+ /**
957
+ * 列出版本
958
+ */
959
+ async listVersions(groupId, fileId) {
960
+ const versionsDir = path.join(this.groupsDir, groupId, 'files', fileId, 'versions')
961
+
962
+ try {
963
+ const entries = await fs.readdir(versionsDir)
964
+ const versions = []
965
+
966
+ for (const entry of entries) {
967
+ if (entry.endsWith('.json')) {
968
+ const versionId = entry.replace('.json', '')
969
+ const versionData = await this.getVersionById(groupId, fileId, versionId)
970
+ if (versionData) {
971
+ versions.push(versionData)
972
+ }
973
+ }
974
+ }
975
+
976
+ // 按创建时间降序排序(新版本在前)
977
+ return versions.sort((a, b) => (b.createTime || 0) - (a.createTime || 0))
978
+ } catch (err) {
979
+ if (err.code === 'ENOENT') {
980
+ return []
981
+ }
982
+ throw err
983
+ }
984
+ }
985
+
986
+ /**
987
+ * 通过版本ID获取版本
988
+ */
989
+ async getVersionById(groupId, fileId, versionId) {
990
+ const versionFile = path.join(this.groupsDir, groupId, 'files', fileId, 'versions', `${versionId}.json`)
991
+ try {
992
+ const content = await fs.readFile(versionFile, 'utf8')
993
+ return JSON.parse(content)
994
+ } catch (err) {
995
+ if (err.code === 'ENOENT') {
996
+ return null
997
+ }
998
+ throw err
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * 通过版本名称获取版本
1004
+ */
1005
+ async getVersionByName(groupId, fileId, versionName) {
1006
+ const versions = await this.listVersions(groupId, fileId)
1007
+ return versions.find(v => v.name === versionName)
1008
+ }
1009
+
1010
+ /**
1011
+ * 创建版本
1012
+ * @param {string} versionName - 用户可见的版本名称
1013
+ */
1014
+ async createVersion(groupId, fileId, versionName, versionData) {
1015
+ // 验证版本数据
1016
+ const validation = this._validateJSON(versionData)
1017
+ if (!validation.valid) {
1018
+ const error = new Error(`Cannot store invalid JSON data: ${validation.error}`)
1019
+ console.error(`[storage-v3] 创建版本失败: ${groupId}/${fileId}/${versionName}`, error.message)
1020
+ throw error
1021
+ }
1022
+
1023
+ // 检查版本名是否重复
1024
+ const existingVersion = await this.getVersionByName(groupId, fileId, versionName)
1025
+ if (existingVersion) {
1026
+ throw new Error(`版本名称已存在: ${versionName}`)
1027
+ }
1028
+
1029
+ const versionsDir = path.join(this.groupsDir, groupId, 'files', fileId, 'versions')
1030
+ await fs.mkdir(versionsDir, { recursive: true })
1031
+
1032
+ // 生成唯一的版本ID
1033
+ const versionId = this.generateFileId()
1034
+
1035
+ const version = {
1036
+ id: versionId,
1037
+ name: versionName,
1038
+ ...versionData,
1039
+ createTime: Date.now(),
1040
+ updateTime: Date.now(),
1041
+ }
1042
+
1043
+ const versionFile = path.join(versionsDir, `${versionId}.json`)
1044
+ await this._writeJSON(versionFile, version)
1045
+
1046
+ // 更新索引中的版本计数
1047
+ const versions = await this.listVersions(groupId, fileId)
1048
+ await this._updateInIndex(groupId, fileId, {
1049
+ versionCount: versions.length,
1050
+ })
1051
+
1052
+ return version
1053
+ }
1054
+
1055
+ /**
1056
+ * 更新版本(通过版本ID)
1057
+ */
1058
+ async updateVersion(groupId, fileId, versionId, updates) {
1059
+ const version = await this.getVersionById(groupId, fileId, versionId)
1060
+ if (!version) {
1061
+ throw new Error(`版本不存在: ${versionId}`)
1062
+ }
1063
+
1064
+ // 如果要更新name,检查是否重复
1065
+ if (updates.name && updates.name !== version.name) {
1066
+ const existingVersion = await this.getVersionByName(groupId, fileId, updates.name)
1067
+ if (existingVersion && existingVersion.id !== versionId) {
1068
+ throw new Error(`版本名称已存在: ${updates.name}`)
1069
+ }
1070
+ }
1071
+
1072
+ const updatedVersion = {
1073
+ ...version,
1074
+ ...updates,
1075
+ id: versionId, // 确保ID不被修改
1076
+ updateTime: Date.now(),
1077
+ }
1078
+
1079
+ const versionFile = path.join(this.groupsDir, groupId, 'files', fileId, 'versions', `${versionId}.json`)
1080
+ await this._writeJSON(versionFile, updatedVersion)
1081
+
1082
+ return updatedVersion
1083
+ }
1084
+
1085
+ /**
1086
+ * 删除版本(通过版本ID)
1087
+ */
1088
+ async deleteVersion(groupId, fileId, versionId) {
1089
+ const versionFile = path.join(this.groupsDir, groupId, 'files', fileId, 'versions', `${versionId}.json`)
1090
+ await fs.unlink(versionFile)
1091
+
1092
+ // 更新索引中的版本计数
1093
+ const versions = await this.listVersions(groupId, fileId)
1094
+ await this._updateInIndex(groupId, fileId, {
1095
+ versionCount: versions.length,
1096
+ })
1097
+ }
1098
+
1099
+ // ========================================
1100
+ // 内部辅助方法
1101
+ // ========================================
1102
+
1103
+ /**
1104
+ * 获取当前组ID
1105
+ */
1106
+ async getCurrentGroupId() {
1107
+ try {
1108
+ const content = await fs.readFile(this.currentGroupFile, 'utf8')
1109
+ return content.trim() || 'default'
1110
+ } catch (err) {
1111
+ // 文件不存在,返回默认值
1112
+ return 'default'
1113
+ }
1114
+ }
1115
+
1116
+ /**
1117
+ * 设置当前组ID
1118
+ */
1119
+ async setCurrentGroupId(groupId) {
1120
+ await fs.writeFile(this.currentGroupFile, groupId, 'utf8')
1121
+ }
1122
+
1123
+ /**
1124
+ * 验证数据是否可以安全序列化为 JSON
1125
+ * @param {*} data - 要验证的数据
1126
+ * @returns {Object} { valid: boolean, error?: string }
1127
+ */
1128
+ _validateJSON(data) {
1129
+ try {
1130
+ const json = JSON.stringify(data)
1131
+ JSON.parse(json)
1132
+ return { valid: true }
1133
+ } catch (err) {
1134
+ return {
1135
+ valid: false,
1136
+ error: `Invalid JSON: ${err.message}`,
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * 写入JSON文件(原子操作)
1143
+ * 使用临时文件+原子重命名模式,确保并发安全
1144
+ */
1145
+ async _writeJSON(filePath, data) {
1146
+ // 确保目标目录存在
1147
+ const dir = path.dirname(filePath)
1148
+ await fs.mkdir(dir, { recursive: true })
1149
+
1150
+ // 生成唯一的临时文件名(时间戳 + 随机数)
1151
+ const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`
1152
+
1153
+ try {
1154
+ // 写入临时文件
1155
+ const content = JSON.stringify(data, null, 2)
1156
+ await fs.writeFile(tmpPath, content, 'utf8')
1157
+
1158
+ // 原子重命名
1159
+ await fs.rename(tmpPath, filePath)
1160
+ } catch (err) {
1161
+ // 清理临时文件
1162
+ try {
1163
+ await fs.unlink(tmpPath)
1164
+ } catch (unlinkErr) {
1165
+ // 忽略清理错误
1166
+ }
1167
+ throw err
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * 获取索引锁(基于 Promise 的互斥锁)
1173
+ */
1174
+ async _acquireIndexLock(groupId) {
1175
+ while (this.indexLocks.has(groupId)) {
1176
+ await this.indexLocks.get(groupId)
1177
+ }
1178
+ let resolve
1179
+ const promise = new Promise(_resolve => { resolve = _resolve })
1180
+ this.indexLocks.set(groupId, promise)
1181
+ return resolve
1182
+ }
1183
+
1184
+ /**
1185
+ * 释放索引锁
1186
+ */
1187
+ _releaseIndexLock(groupId, resolve) {
1188
+ this.indexLocks.delete(groupId)
1189
+ resolve()
1190
+ }
1191
+
1192
+ /**
1193
+ * 添加到索引(带去重检查和并发保护)
1194
+ */
1195
+ async _addToIndex(groupId, fileEntry) {
1196
+ const release = await this._acquireIndexLock(groupId)
1197
+ try {
1198
+ // ⚠️ 强制清除缓存,确保读取最新数据
1199
+ this.invalidateIndex(groupId)
1200
+ const index = await this.getIndex(groupId)
1201
+
1202
+ // 检查文件是否已存在索引中(防止并发重复添加)
1203
+ const exists = index.files.some(f => f.id === fileEntry.id)
1204
+ if (exists) {
1205
+ console.log(`[storage-v3] 文件已在索引中,跳过添加: ${groupId}/${fileEntry.id}`)
1206
+ return
1207
+ }
1208
+
1209
+ index.files.push(fileEntry)
1210
+ await this.updateIndex(groupId, index)
1211
+ } finally {
1212
+ this._releaseIndexLock(groupId, release)
1213
+ }
1214
+ }
1215
+
1216
+ /**
1217
+ * 更新索引中的文件(带并发保护)
1218
+ * 如果文件不在索引中,自动添加到索引
1219
+ */
1220
+ async _updateInIndex(groupId, fileId, updates) {
1221
+ const release = await this._acquireIndexLock(groupId)
1222
+ try {
1223
+ // 强制清除缓存,确保读取最新数据
1224
+ this.invalidateIndex(groupId)
1225
+ const index = await this.getIndex(groupId)
1226
+ const fileIndex = index.files.findIndex(f => f.id === fileId)
1227
+
1228
+ if (fileIndex !== -1) {
1229
+ // 文件存在于索引,更新
1230
+ index.files[fileIndex] = {
1231
+ ...index.files[fileIndex],
1232
+ ...updates,
1233
+ updateTime: Date.now(),
1234
+ }
1235
+ await this.updateIndex(groupId, index)
1236
+ } else {
1237
+ // 文件不在索引中,添加到索引
1238
+ console.log(`[storage-v3] 文件不在索引中,自动添加: ${groupId}/${fileId}`)
1239
+ // 注意:这里已经持有锁,直接操作索引而不是递归调用 _addToIndex
1240
+ const exists = index.files.some(f => f.id === fileId)
1241
+ if (!exists) {
1242
+ index.files.push({
1243
+ id: fileId,
1244
+ ...updates,
1245
+ createTime: updates.createTime || Date.now(),
1246
+ updateTime: updates.updateTime || Date.now(),
1247
+ })
1248
+ await this.updateIndex(groupId, index)
1249
+ }
1250
+ }
1251
+ } finally {
1252
+ this._releaseIndexLock(groupId, release)
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * 从索引中移除(带并发保护)
1258
+ */
1259
+ async _removeFromIndex(groupId, fileId) {
1260
+ const release = await this._acquireIndexLock(groupId)
1261
+ try {
1262
+ this.invalidateIndex(groupId)
1263
+ const index = await this.getIndex(groupId)
1264
+ index.files = index.files.filter(f => f.id !== fileId)
1265
+ await this.updateIndex(groupId, index)
1266
+ } finally {
1267
+ this._releaseIndexLock(groupId, release)
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * 更新组统计信息
1273
+ */
1274
+ async _updateGroupStats(groupId) {
1275
+ const index = await this.getIndex(groupId)
1276
+ const fileCount = index.files.length
1277
+ const totalSize = index.files.reduce((sum, file) => sum + (file.size || 0), 0)
1278
+
1279
+ await this.updateGroup(groupId, { fileCount, totalSize })
1280
+ }
1281
+
1282
+ /**
1283
+ * 计算文件大小
1284
+ */
1285
+ _calculateSize(fileData) {
1286
+ return Buffer.byteLength(JSON.stringify(fileData), 'utf8')
1287
+ }
1288
+
1289
+ /**
1290
+ * 解析 URL 并提取增强字段(domain、pathname、urlHash)
1291
+ * @param {string} url - 完整的 URL
1292
+ * @returns {Object} { domain, pathname, urlHash }
1293
+ */
1294
+ _parseUrlFields(url) {
1295
+ try {
1296
+ const urlObj = new URL(url)
1297
+ const domain = urlObj.hostname || ''
1298
+ const pathname = urlObj.pathname || '/'
1299
+
1300
+ // 生成 URL 哈希(用于快速查找)
1301
+ const crypto = require('crypto')
1302
+ const urlHash = crypto.createHash('md5').update(url).digest('hex').substring(0, 8)
1303
+
1304
+ return { domain, pathname, urlHash }
1305
+ } catch (err) {
1306
+ // URL 解析失败,返回默认值
1307
+ console.warn(`[storage-v3] URL 解析失败: ${url}`, err.message)
1308
+ return {
1309
+ domain: '',
1310
+ pathname: '',
1311
+ urlHash: '',
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ // ========================================
1317
+ // 通用属性操作(用于 api-list-updated 等全局标志)
1318
+ // ========================================
1319
+
1320
+ /**
1321
+ * 读取通用属性
1322
+ */
1323
+ async getGenericProperty(key) {
1324
+ try {
1325
+ // 每次都从文件读取,避免多进程/多实例缓存不一致
1326
+ try {
1327
+ const content = await fs.readFile(this.propertiesFile, 'utf8')
1328
+ const properties = JSON.parse(content)
1329
+ return properties[key]
1330
+ } catch (err) {
1331
+ // 文件不存在,返回 undefined
1332
+ return undefined
1333
+ }
1334
+ } catch (err) {
1335
+ return null
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * 设置通用属性
1341
+ */
1342
+ async setGenericProperty(key, value) {
1343
+ try {
1344
+ // 每次都从文件读取最新数据
1345
+ let properties = {}
1346
+ try {
1347
+ const content = await fs.readFile(this.propertiesFile, 'utf8')
1348
+ properties = JSON.parse(content)
1349
+ } catch (err) {
1350
+ // 文件不存在,使用空对象
1351
+ }
1352
+
1353
+ // 更新属性
1354
+ properties[key] = value
1355
+
1356
+ // 写回文件
1357
+ await fs.writeFile(
1358
+ this.propertiesFile,
1359
+ JSON.stringify(properties, null, 2),
1360
+ 'utf8',
1361
+ )
1362
+ } catch (err) {
1363
+ console.error('[storage-v3] setGenericProperty failed:', key, err.message)
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ module.exports = FileSystemStorage