@whispering233/ai-editor-server 0.0.33 → 0.0.38

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.
Files changed (56) hide show
  1. package/client-dist/assets/{index-oxaM6dHH.css → index-IF0z2BR1.css} +1 -1
  2. package/client-dist/assets/{index-tfvJfexp.js → index-Sx1BGdOS.js} +196 -196
  3. package/client-dist/index.html +2 -2
  4. package/dist/backup.d.ts +135 -25
  5. package/dist/backup.d.ts.map +1 -1
  6. package/dist/backup.js +348 -68
  7. package/dist/backup.js.map +1 -1
  8. package/dist/cloud/auto-push.d.ts +39 -0
  9. package/dist/cloud/auto-push.d.ts.map +1 -0
  10. package/dist/cloud/auto-push.js +145 -0
  11. package/dist/cloud/auto-push.js.map +1 -0
  12. package/dist/cloud/device.d.ts +3 -0
  13. package/dist/cloud/device.d.ts.map +1 -0
  14. package/dist/cloud/device.js +15 -0
  15. package/dist/cloud/device.js.map +1 -0
  16. package/dist/cloud/state.d.ts +59 -0
  17. package/dist/cloud/state.d.ts.map +1 -0
  18. package/dist/cloud/state.js +162 -0
  19. package/dist/cloud/state.js.map +1 -0
  20. package/dist/cloud/sync.d.ts +102 -0
  21. package/dist/cloud/sync.d.ts.map +1 -0
  22. package/dist/cloud/sync.js +447 -0
  23. package/dist/cloud/sync.js.map +1 -0
  24. package/dist/cloud/webdav.d.ts +69 -0
  25. package/dist/cloud/webdav.d.ts.map +1 -0
  26. package/dist/cloud/webdav.js +326 -0
  27. package/dist/cloud/webdav.js.map +1 -0
  28. package/dist/device-name.d.ts +3 -0
  29. package/dist/device-name.d.ts.map +1 -0
  30. package/dist/device-name.js +13 -0
  31. package/dist/device-name.js.map +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/middleware/error.d.ts +8 -2
  36. package/dist/middleware/error.d.ts.map +1 -1
  37. package/dist/middleware/error.js +18 -1
  38. package/dist/middleware/error.js.map +1 -1
  39. package/dist/middleware/project.d.ts +27 -3
  40. package/dist/middleware/project.d.ts.map +1 -1
  41. package/dist/middleware/project.js +79 -6
  42. package/dist/middleware/project.js.map +1 -1
  43. package/dist/routes/cloud.d.ts +9 -0
  44. package/dist/routes/cloud.d.ts.map +1 -0
  45. package/dist/routes/cloud.js +206 -0
  46. package/dist/routes/cloud.js.map +1 -0
  47. package/dist/routes/delta.d.ts.map +1 -1
  48. package/dist/routes/delta.js +20 -2
  49. package/dist/routes/delta.js.map +1 -1
  50. package/dist/routes/project.d.ts.map +1 -1
  51. package/dist/routes/project.js +35 -18
  52. package/dist/routes/project.js.map +1 -1
  53. package/dist/routes/relation.d.ts.map +1 -1
  54. package/dist/routes/relation.js +33 -3
  55. package/dist/routes/relation.js.map +1 -1
  56. package/package.json +7 -7
package/dist/backup.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // 自动备份与恢复(B2.2 + B2.5 + B2.6)
2
2
  //
3
3
  // 职责:
4
- // - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip
5
- // (复用导出打包;毫秒精度;kind 段:手动备份落 -m[-<名称>]、
6
- // 自动备份/覆盖前快照纯时间戳、重命名后自动备份落 -a-<名称>)
4
+ // - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip
5
+ // (复用导出打包;毫秒精度;类型段:手动备份落「手动」、自动备份/覆盖前快照落「自动」)
7
6
  // - 保留策略:每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧(含覆盖前快照;
8
- // 新旧格式文件名均参与——parseBackupFileName 兼容解析)
7
+ // **只有唯一命名格式的文件参与**——parseBackupFileName 解析,旧命名份留盘但不识别)
8
+ // - 升级兜底:打开项目时若 .backups/ 有文件但无一可解析(旧命名残留)→ ensureParseableBackup
9
+ // 立即生成一份新格式备份(不重命名旧份:旧份无设备/统计信息,硬补会谎报)
9
10
  // - 自动定时器:跟随当前项目生命周期(middleware/project.ts setCurrentProject 挂载启停),
10
11
  // 有变更才备份(三文件 + data.db-wal 伴生文件 mtime 与 .backups/ 最新备份时间比较,
11
- // 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db 变更)
12
+ // 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db 变更);
13
+ // 频率关闭时回落 `setProjectTick` 注入的兑底间隔(卡 7:自动推送挂在同一条 tick 链上,
14
+ // 按 2h 排程——不新起第二个定时器)
12
15
  // - 备份包校验(restore 与 import 共用):zip 解析/白名单/三文件齐全/顶层/
13
16
  // data.db user_version 三态分流(绝不静默重建)
14
17
  // - restore:fileName 白名单 → 覆盖前自动快照 → 校验 → 原子替换三文件
@@ -22,8 +25,9 @@ import { basename, dirname, join } from "node:path";
22
25
  import { Unzip, UnzipInflate, zipSync } from "fflate";
23
26
  import { BACKUP_FREQUENCIES, DEFAULT_BACKUP_FREQUENCY_MINUTES, formatBackupFileName, MAX_BACKUPS_PER_PROJECT, MAX_BACKUP_NAME_LENGTH, parseBackupFileName, sanitizeBackupName } from "@whispering233/ai-editor-shared";
24
27
  import { PROJECT_EXPORT_FILE_NAMES } from "@whispering233/ai-editor-shared/schemas";
25
- import { closeDatabase, checkpointWal, DATA_DB_FILE_NAME, ensureSchemaCompatible, getUserVersion, hasMigrationPath, openDatabase, OUTLINE_FILE_NAME, PROJECT_FILE_NAME, readAgentsFile, readProjectFile, SCHEMA_VERSION, writeAgentsFile, } from "@whispering233/ai-editor-db";
28
+ import { AGENTS_FILE_NAME, closeDatabase, checkpointWal, DATA_DB_FILE_NAME, ensureSchemaCompatible, getBackupStats, getUserVersion, hasMigrationPath, openDatabase, OUTLINE_FILE_NAME, PROJECT_FILE_NAME, readAgentsFile, readProjectFile, SCHEMA_VERSION, writeAgentsFile, } from "@whispering233/ai-editor-db";
26
29
  import { SESSIONS_DIR_NAME } from "@whispering233/ai-editor-agent";
30
+ import { currentDeviceName } from "./cloud/device.js";
27
31
  import { HttpError } from "./middleware/error.js";
28
32
  /** 备份目录名(项目目录内 .backups/,随书籍移动自然携带) */
29
33
  export const BACKUPS_DIR_NAME = ".backups";
@@ -35,7 +39,7 @@ const REFERENCE_DIR_NAME = "references";
35
39
  * 白名单前缀 + 递归打包 + 参与变更判定 + 恢复时整体覆盖。
36
40
  * `SESSIONS_DIR_NAME` 取自 agent 包的 pi 运行时(会话目录的唯一事实来源;pi 的 session 文件落在此目录)。
37
41
  */
38
- const PACKED_DIR_NAMES = [REFERENCE_DIR_NAME, SESSIONS_DIR_NAME];
42
+ export const PACKED_DIR_NAMES = [REFERENCE_DIR_NAME, SESSIONS_DIR_NAME];
39
43
  /** 备份包条目白名单判定(三文件 + 打包目录前缀——逐名比对天然防 zip 路径穿越;
40
44
  * 目录子路径拒绝含 `..` 的条目防相对路径逃逸) */
41
45
  export function isAllowedBackupEntry(name) {
@@ -81,6 +85,63 @@ function overwriteDirFromEntries(dir, dirName, entries) {
81
85
  writeFileAtomic(target, entries[key]);
82
86
  }
83
87
  }
88
+ /**
89
+ * 用备份条目与本机现有文件做**并集合并**(云拉取语义;本地 restore 不走这里):
90
+ * 逐文件三方比较(本机 / 云端那份 / 基线 `baseEntries`)——
91
+ *
92
+ * | 本机 | 云端 | 基线 | 动作 |
93
+ * | 有 | 有 | — | 写云端那份(同名 = 同一资产) |
94
+ * | 有 | 无 | ✔ | 删本机(云端删除传播) |
95
+ * | 有 | 无 | ✘ | 保留本机(本机新增) |
96
+ * | 无 | 有 | ✔ | **不写回**(本机删除优先,不被复活) |
97
+ * | 无 | 有 | ✘ | 写进来(云端新增) |
98
+ *
99
+ * 基线由 `cloud.json` 的 `books[<id>].baseEntries`(= 最近一次推送/拉取包的条目)提供。
100
+ */
101
+ function mergeDirFromEntries(dir, dirName, entries, baseEntries) {
102
+ const baseDir = join(dir, dirName);
103
+ const rel = (key) => key.slice(dirName.length + 1);
104
+ const localSet = new Set(listDirFiles(dir, dirName));
105
+ const cloudKeys = Object.keys(entries).filter((k) => k.startsWith(`${dirName}/`) && rel(k) !== "");
106
+ const cloudSet = new Set(cloudKeys.map(rel));
107
+ const baseSet = new Set(baseEntries.filter((e) => e.startsWith(`${dirName}/`) && rel(e) !== "").map(rel));
108
+ const counts = { kept: 0, written: 0, removed: 0 };
109
+ const safeSegments = (path) => {
110
+ const segments = path.split("/");
111
+ return segments.includes("..") ? null : segments; // 白名单已校验,双保险
112
+ };
113
+ // 本机侧:云端没有的份,按基线判「云端删了它」还是「本机新增」
114
+ for (const path of localSet) {
115
+ if (cloudSet.has(path))
116
+ continue; // 两边都有 → 由云端侧写云端那份
117
+ const segments = safeSegments(path);
118
+ if (segments === null)
119
+ continue;
120
+ if (baseSet.has(path)) {
121
+ rmSync(join(baseDir, ...segments), { force: true });
122
+ counts.removed += 1;
123
+ }
124
+ else {
125
+ counts.kept += 1;
126
+ }
127
+ }
128
+ // 云端侧:本机没有的份,按基线判「本机删了它」(不复活)还是「云端新增」
129
+ for (const key of cloudKeys) {
130
+ const path = rel(key);
131
+ const segments = safeSegments(path);
132
+ if (segments === null)
133
+ continue;
134
+ const isLocalNew = !localSet.has(path);
135
+ if (isLocalNew && baseSet.has(path))
136
+ continue; // 本机删除优先
137
+ const target = join(baseDir, ...segments);
138
+ mkdirSync(dirname(target), { recursive: true });
139
+ writeFileAtomic(target, entries[key]);
140
+ if (isLocalNew)
141
+ counts.written += 1;
142
+ }
143
+ return counts;
144
+ }
84
145
  /** 解压总字节预算(200MB,zip 炸弹防御——与 import 同款,restore 复用) */
85
146
  const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
86
147
  /**
@@ -93,16 +154,16 @@ const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
93
154
  const BACKUP_CHANGE_TOLERANCE_MS = 1000;
94
155
  /**
95
156
  * 备份文件名白名单校验(renameBackup / restoreBackup 共用,防路径穿越):
96
- * parseBackupFileName 全格式校验(时间戳部分 ^$ 锚定纯数字 + 名称部分拒绝 /\\,
97
- * 天然防路径分隔符与 `..` 穿越;含旧格式兼容解析);非法 → 400 VALIDATION_ERROR(文案统一)。
157
+ * parseBackupFileName 全格式校验(时间戳部分 ^$ 锚定纯数字 + 设备/标签部分拒绝 /\\,
158
+ * 天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 同样被拒);非法 → 400 VALIDATION_ERROR(文案统一)。
98
159
  *
99
- * @returns 解析结果(校验通过即返回非 null——调用方直接取 time/kind/name,免二次解析)
160
+ * @returns 解析结果(校验通过即返回非 null——调用方直接取 time/kind/name/device/stats,免二次解析)
100
161
  * @throws HttpError 400 VALIDATION_ERROR(文件名格式非法)
101
162
  */
102
163
  function assertBackupFileNameFormat(fileName) {
103
164
  const parsed = parseBackupFileName(fileName);
104
165
  if (parsed === null) {
105
- throw new HttpError(400, "VALIDATION_ERROR", `备份文件名格式非法(仅接受 <YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip 时间戳格式): ${fileName}`);
166
+ throw new HttpError(400, "VALIDATION_ERROR", `备份文件名格式非法(仅接受 <YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip): ${fileName}`);
106
167
  }
107
168
  return parsed;
108
169
  }
@@ -131,38 +192,33 @@ export function createBackupZip(project) {
131
192
  return zipSync(zipEntries, { level: 6 });
132
193
  }
133
194
  /**
134
- * 生成不冲突的备份文件名:`<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`(毫秒精度 +
135
- * kind 段;shared formatBackupFileName;kind 缺省 auto——纯时间戳;带自定义名称
136
- * `-a-<名称>`(auto)/ `-m-<名称>`(manual))。
137
- *
138
- * 同毫秒冲突(如「立即备份 + restore 覆盖前快照」连续触发,理论罕见)处理:
139
- * 时间戳 +1 毫秒循环去重,**保持 <YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip 格式**——
140
- * parseBackupFileName 解析与 restore 白名单校验不受影响。
195
+ * 生成不冲突的备份文件名:`<时间戳>-<类型>-<设备>[-<标签>]-人物N-设定N-章N.zip`(毫秒精度 +
196
+ * 设备段 + 统计段;shared formatBackupFileName)。同毫秒冲突(如「立即备份 + restore 覆盖前
197
+ * 快照」连续触发,理论罕见)处理:时间戳 +1 毫秒循环去重,**保持格式契约可解析**。
141
198
  *
142
199
  * @returns { fileName, date }——date 为最终去重后的时间戳(与 fileName 时间戳段一致,
143
200
  * 调用方直接用于构造 createdAt,免 parse 回读)
144
201
  */
145
202
  function uniqueBackupFileName(backupsDir, date, opts) {
146
- const kind = opts?.kind ?? "auto";
147
- const name = opts?.name;
148
- let fileName = formatBackupFileName(date, { kind, name });
203
+ const { kind = "auto", name, device, stats } = opts;
204
+ let fileName = formatBackupFileName(date, { kind, name, device, stats });
149
205
  while (existsSync(join(backupsDir, fileName))) {
150
206
  date = new Date(date.getTime() + 1);
151
- fileName = formatBackupFileName(date, { kind, name });
207
+ fileName = formatBackupFileName(date, { kind, name, device, stats });
152
208
  }
153
209
  return { fileName, date };
154
210
  }
155
211
  /**
156
212
  * 立即备份当前项目(手动触发 / 自动定时器 / restore 覆盖前快照共用):
157
- * 打包 → 写入 .backups/<时间戳>[-<kind>][-<名称>].zip(毫秒精度;同毫秒 +1ms 去重)→
158
- * 触发保留策略清理(失败不阻塞)。
213
+ * 打包 → 写入 .backups/<时间戳>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip
214
+ * (唯一命名格式,毫秒精度;同毫秒 +1ms 去重)→ 触发保留策略清理(失败不阻塞)。
159
215
  * 写盘失败向上抛(errorHandler → 500 INTERNAL_ERROR,POST /backup 语义)。
160
216
  *
161
- * @param opts.kind 备份类型(缺省 "auto"):手动触发传 "manual"(文件名落 -m 段);
162
- * 自动备份/覆盖前快照不传(auto,纯时间戳)
217
+ * @param opts.kind 备份类型(缺省 "auto"):手动触发传 "manual"(文件名落「手动」段);
218
+ * 自动备份/覆盖前快照不传(auto,「自动」段)
163
219
  * @param opts.name 手动备份自定义名称(仅带名称的手动/重命名场景传入):
164
220
  * sanitizeBackupName 是名称校验/规范化**唯一执行点**——非法(含路径分隔符/超长/纯点)→
165
- * 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name,文件名保持纯时间戳。
221
+ * 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name,文件名无标签段。
166
222
  * 注意:name 仅在 opts.name !== undefined 且 sanitize 通过后传入 formatBackupFileName。
167
223
  */
168
224
  export function writeBackup(project, opts) {
@@ -175,10 +231,14 @@ export function writeBackup(project, opts) {
175
231
  }
176
232
  name = sanitized;
177
233
  }
234
+ // 统计快照在打包前取(同一连接同一时刻视角);设备名 = 当前生效值
235
+ //(云端配置的 device 优先,缺省 = 本机 hostname 派生,见 cloud/device.ts)
236
+ const stats = getBackupStats(project.db, project.root);
237
+ const device = currentDeviceName();
178
238
  const zip = createBackupZip(project);
179
239
  const backupsDir = join(project.root, BACKUPS_DIR_NAME);
180
240
  mkdirSync(backupsDir, { recursive: true });
181
- const { fileName, date } = uniqueBackupFileName(backupsDir, new Date(), { kind, name });
241
+ const { fileName, date } = uniqueBackupFileName(backupsDir, new Date(), { kind, name, device, stats });
182
242
  writeFileSync(join(backupsDir, fileName), zip); // 失败抛错 → 500(不产出半截备份)
183
243
  pruneBackups(backupsDir); // 清理失败仅记日志,不阻塞备份主流程
184
244
  return {
@@ -187,25 +247,64 @@ export function writeBackup(project, opts) {
187
247
  createdAt: toIso(date), // date = 最终去重后的时间戳,与 fileName 时间戳段一致(无状态语义)
188
248
  kind,
189
249
  ...(name !== undefined ? { name } : {}),
250
+ device,
251
+ stats,
190
252
  };
191
253
  }
192
254
  /**
193
- * 重命名备份(POST /project/backup/rename):**只改名称段**,时间戳与 kind 保持。
255
+ * 升级兜底(卡 A):`.backups/` 里**有文件但没有任何一份可解析**(旧命名残留形态)→
256
+ * 立即 `writeBackup(project, { kind: "auto" })` 生成一份新格式备份并返回 true;
257
+ * 已有可解析份(或目录为空/不存在)→ 什么都不做,返回 false(**不重复备份**)。
258
+ *
259
+ * 为什么需要:旧命名份不再被解析(列表/恢复/保留策略均不认),若磁盘上只剩旧命名且
260
+ * **自动备份频率关闭**,用户点「立即备份」前列表会是空的;这一调用保证最新数据马上有一份
261
+ * 新格式可列/可恢复的档。**不重命名旧份**——旧份无设备/统计信息,硬补会谎报(统计必须描述该备份的内容)。
194
262
  *
263
+ * best-effort:任何异常只记日志并返回 false(打开项目不得因兜底备份失败而失败)。
195
264
  *
196
- * 1. fileName 白名单校验:仅接受 parseBackupFileName 可解析的时间戳格式
197
- * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`(含旧格式兼容,^$ 锚定 + 名称部分拒绝 /\\,
198
- * 天然防路径分隔符与 `..` 穿越)→ 非法 400 VALIDATION_ERROR
265
+ * @returns 本次是否补了一份新格式备份
266
+ */
267
+ export function ensureParseableBackup(project) {
268
+ try {
269
+ let files;
270
+ try {
271
+ files = readdirSync(join(project.root, BACKUPS_DIR_NAME), { withFileTypes: true })
272
+ .filter((e) => e.isFile())
273
+ .map((e) => e.name);
274
+ }
275
+ catch {
276
+ return false; // 目录不存在/不可读 → 没有「有文件但无一可解析」的形态
277
+ }
278
+ if (files.length === 0)
279
+ return false;
280
+ if (files.some((f) => parseBackupFileName(f) !== null))
281
+ return false; // 已有可解析份 → 不动
282
+ writeBackup(project, { kind: "auto" });
283
+ return true;
284
+ }
285
+ catch (err) {
286
+ console.error("[backup] 命名迁移兜底备份失败:", err);
287
+ return false;
288
+ }
289
+ }
290
+ /**
291
+ * 重命名备份(POST /project/backup/rename):**只改名称段**,时间戳/类型/设备/统计保持。
292
+ *
293
+ *
294
+ * 1. fileName 白名单校验:仅接受 parseBackupFileName 可解析的唯一命名格式
295
+ * `<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip`(^$ 锚定 + 设备/标签
296
+ * 部分拒绝 /\\,天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 无从改名)→ 非法 400 VALIDATION_ERROR
199
297
  * 2. 文件不存在 → 404 VALIDATION_ERROR
200
298
  * 3. 新名称解析:请求未传 name → 清除名称段;传空串/纯空白 → 清除名称段;
201
299
  * 非空 → sanitizeBackupName 规范化(非法 → 400 VALIDATION_ERROR,文案同 writeBackup)
202
- * 4. 新文件名 = formatBackupFileName(parsed.time, { kind: parsed.kind, name: 新名称 })——
203
- * kind 不随重命名改变(auto 重命名后仍落 -a- 段、manual 仍落 -m- 段)
300
+ * 4. 新文件名 = formatBackupFileName(parsed.time, { kind, name: 新名称, device, stats })——
301
+ * kind 不随重命名改变(auto 仍是「自动」段、manual 仍是「手动」段);**设备与统计段保持原份的值**
302
+ * (它们描述该备份的来源与内容,不拿当前项目状态凑)
204
303
  * 5. 幂等:新文件名 === 原文件名(如重命名为相同名称)→ 不移动文件,直接返回当前条目
205
304
  * (重新 stat 取 size;stat 失败 → 404「备份不存在」)
206
305
  * 6. **目标冲突防御(oracle P1-1)**:新文件名已存在(≠ 原文件)→ 409
207
- * BACKUP_TARGET_EXISTS——POSIX rename 目标存在时静默替换,可达路径:旧秒级改名后毫秒补
208
- * 000 撞上毫秒为 0 的自动备份、同毫秒双 manual(T-m.zip + T-m-X.zip)清名覆盖;显式拒绝
306
+ * BACKUP_TARGET_EXISTS——POSIX rename 目标存在时静默替换,可达路径:同毫秒双 manual
307
+ * (`<T>-手动-设备-A.zip` 清标签后撞上 `<T>-手动-设备.zip`)等;显式拒绝
209
308
  * 7. renameSync 同目录原子改名(失败向上抛 → 500);改名后统一 stat 取 size
210
309
  * (改名瞬间被删等竞态 → 404「备份不存在」)
211
310
  *
@@ -230,8 +329,13 @@ export function renameBackup(project, fileName, name) {
230
329
  if (sanitized !== null)
231
330
  nextName = sanitized;
232
331
  }
233
- // 3. 新文件名(时间戳与 kind 保持原备份——kind 不随重命名改变)
234
- const newFileName = formatBackupFileName(parsed.time, { kind: parsed.kind, name: nextName });
332
+ // 3. 新文件名(时间戳/类型/设备/统计段保持原备份——重命名只改标签段)
333
+ const newFileName = formatBackupFileName(parsed.time, {
334
+ kind: parsed.kind,
335
+ name: nextName,
336
+ device: parsed.device,
337
+ stats: parsed.stats,
338
+ });
235
339
  // 4. 幂等:名称未变 → 不移动文件,返回当前条目(重新 stat 取 size;读不到 → 404)
236
340
  if (newFileName === fileName) {
237
341
  try {
@@ -241,6 +345,8 @@ export function renameBackup(project, fileName, name) {
241
345
  createdAt: toIso(parsed.time),
242
346
  kind: parsed.kind,
243
347
  ...(nextName !== undefined ? { name: nextName } : {}),
348
+ device: parsed.device,
349
+ stats: parsed.stats,
244
350
  };
245
351
  }
246
352
  catch {
@@ -248,7 +354,7 @@ export function renameBackup(project, fileName, name) {
248
354
  }
249
355
  }
250
356
  // 5. 目标冲突防御(oracle P1-1):POSIX rename 目标存在时静默替换(数据丢失风险)——
251
- // 可达路径:旧秒级改名后毫秒补 000 撞上毫秒为 0 的自动备份、同毫秒双 manual 清名覆盖等;
357
+ // 可达路径:同毫秒双 manual(`<T>-手动-设备-A.zip` 清标签后撞上 `<T>-手动-设备.zip`)等;
252
358
  // 显式 409 拒绝。幂等分支(target === fileName)已在第 4 步提前返回,此处必然 target ≠ fileName。
253
359
  if (existsSync(join(backupsDir, newFileName))) {
254
360
  throw new HttpError(409, "BACKUP_TARGET_EXISTS", `目标备份文件名已存在: ${newFileName}`);
@@ -269,6 +375,8 @@ export function renameBackup(project, fileName, name) {
269
375
  createdAt: toIso(parsed.time),
270
376
  kind: parsed.kind,
271
377
  ...(nextName !== undefined ? { name: nextName } : {}),
378
+ device: parsed.device,
379
+ stats: parsed.stats,
272
380
  };
273
381
  }
274
382
  /**
@@ -322,6 +430,8 @@ export function listBackups(project) {
322
430
  createdAt: toIso(parsed.time),
323
431
  kind: parsed.kind,
324
432
  ...(parsed.name !== undefined ? { name: parsed.name } : {}),
433
+ device: parsed.device,
434
+ stats: parsed.stats,
325
435
  };
326
436
  }
327
437
  catch {
@@ -537,15 +647,18 @@ function latestBackupTime(backupsDir) {
537
647
  return latest;
538
648
  }
539
649
  /**
540
- * 三文件 + data.db-wal 伴生文件是否在 since 之后有变更(「任一 mtime 晚于
541
- * 上次备份时刻」;F2 修订:data.db 以 WAL 模式运行,普通写事务只追加 -wal 伴生文件、
542
- * 主文件 mtime 不变,故 data.db-wal 一并纳入判定):
543
- * 任一文件 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
544
- * 跳过,让备份管道报错暴露损坏);**data.db-wal 缺失 ≠ 变更**(无未 checkpoint 的写,
545
- * 属正常状态,跳过——与主文件缺失语义不同,否则每次 tick 都误备份,产生垃圾备份);
546
- * mtime 判定见 BACKUP_CHANGE_TOLERANCE_MS 注释。
650
+ * 三文件 + `data.db-wal` 伴生文件 + 两个打包目录,是否在 since 之后有变更
651
+ *(「任一 mtime 晚于上次备份时刻」):
652
+ * - 三文件:任一 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
653
+ * 跳过,让备份管道报错暴露损坏)
654
+ * - `data.db-wal`(F2):普通写事务只追加 `-wal`、主文件 mtime 不变;**wal 缺失 ≠ 变更**
655
+ * (无未 checkpoint 的写,属正常状态,否则每次 tick 都误备份)
656
+ * - `references/` 与 `sessions/`:目录内**任一文件** mtime 命中,或**目录自身** mtime 命中
657
+ * (F3:删除文件不刷新任何剩余文件的 mtime,只看文件会漏检删除);目录缺失(ENOENT)
658
+ * = 无文件、不算变更;其余错误防御视为有变更
659
+ * mtime 判定容差见 BACKUP_CHANGE_TOLERANCE_MS 注释。
547
660
  */
548
- function hasFileChangesSince(project, since) {
661
+ export function hasFileChangesSince(project, since) {
549
662
  const limit = since.getTime() + BACKUP_CHANGE_TOLERANCE_MS;
550
663
  for (const name of [PROJECT_FILE_NAME, OUTLINE_FILE_NAME, DATA_DB_FILE_NAME]) {
551
664
  try {
@@ -568,11 +681,19 @@ function hasFileChangesSince(project, since) {
568
681
  if (err.code !== "ENOENT")
569
682
  return true;
570
683
  }
571
- // 打包目录(references/ 与 sessions/):内文件任一 mtime 晚于 limit → 有变更——
572
- // 本地新增/外部编辑 md 文档、新增聊天会话同样触发自动备份;目录缺失 = 无文件跳过;
684
+ // 打包目录(references/ 与 sessions/):目录自身或其内任一文件 mtime 晚于 limit → 有变更——
685
+ // 本地新增/外部编辑 md 文档、新增聊天会话、**删除文件**同样触发自动备份;
573
686
  // 遍历竞态(读取中删除)→ 防御视为无变更(下一 tick 重检)
574
687
  try {
575
688
  for (const dirName of PACKED_DIR_NAMES) {
689
+ try {
690
+ if (statSync(join(project.root, dirName)).mtimeMs > limit)
691
+ return true;
692
+ }
693
+ catch (err) {
694
+ if (err.code !== "ENOENT")
695
+ return true;
696
+ }
576
697
  for (const rel of listDirFiles(project.root, dirName)) {
577
698
  if (statSync(join(project.root, dirName, rel)).mtimeMs > limit)
578
699
  return true;
@@ -584,6 +705,120 @@ function hasFileChangesSince(project, since) {
584
705
  }
585
706
  return false;
586
707
  }
708
+ /**
709
+ * mtime 变更判定内核(`hasLocalEditsSince` 与 `hasAuthoringChangesSince` 共用,避免两份漂移):
710
+ * 三文件(`data.db`/`-wal` 带 `BACKUP_CHANGE_TOLERANCE_MS` 容差,其余**严格** `>`)→ 额外单文件
711
+ * (`scope.extraFiles`,严格;ENOENT = 该可选文件不存在,不算变更)→ `scope.dirs` 各目录
712
+ * (**目录自身**与其内任一文件 mtime 命中即算「有改动」——删除文件不刷新剩余文件 mtime,
713
+ * 只看文件会漏检删除;目录缺失 = 无文件,不算变更)。
714
+ *
715
+ * 容差只给 `data.db` 与 `-wal` 的原因:备份/推送管道内的 `wal_checkpoint` 会把其 mtime 刷到
716
+ * 同步时刻,严格比较会自激误判「永远有改动」;其余文件不会被管道写入,给容差反而会让
717
+ * 「基准后 1 秒内的改动」被长期漏判(基准是不推进的固定值,直到下次同步才前移)。
718
+ */
719
+ function hasChangesSince(project, since, scope) {
720
+ const strictLimit = since.getTime();
721
+ const tolerantLimit = strictLimit + BACKUP_CHANGE_TOLERANCE_MS;
722
+ for (const name of [PROJECT_FILE_NAME, OUTLINE_FILE_NAME]) {
723
+ try {
724
+ if (statSync(join(project.root, name)).mtimeMs > strictLimit)
725
+ return true;
726
+ }
727
+ catch {
728
+ return true; // 主文件缺失 = 损坏,视为有改动(不静默)
729
+ }
730
+ }
731
+ try {
732
+ if (statSync(join(project.root, DATA_DB_FILE_NAME)).mtimeMs > tolerantLimit)
733
+ return true;
734
+ }
735
+ catch {
736
+ return true;
737
+ }
738
+ const walPath = join(project.root, `${DATA_DB_FILE_NAME}-wal`);
739
+ try {
740
+ if (statSync(walPath).mtimeMs > tolerantLimit)
741
+ return true;
742
+ }
743
+ catch (err) {
744
+ if (err.code !== "ENOENT")
745
+ return true;
746
+ }
747
+ for (const name of scope.extraFiles ?? []) {
748
+ try {
749
+ if (statSync(join(project.root, name)).mtimeMs > strictLimit)
750
+ return true;
751
+ }
752
+ catch (err) {
753
+ if (err.code !== "ENOENT")
754
+ return true;
755
+ }
756
+ }
757
+ try {
758
+ for (const dirName of scope.dirs) {
759
+ try {
760
+ if (statSync(join(project.root, dirName)).mtimeMs > strictLimit)
761
+ return true;
762
+ }
763
+ catch (err) {
764
+ if (err.code !== "ENOENT")
765
+ return true;
766
+ }
767
+ for (const rel of listDirFiles(project.root, dirName)) {
768
+ if (statSync(join(project.root, dirName, rel)).mtimeMs > strictLimit)
769
+ return true;
770
+ }
771
+ }
772
+ }
773
+ catch {
774
+ // 遍历竞态(读取中删除):下一次状态检查重检
775
+ }
776
+ return false;
777
+ }
778
+ /**
779
+ * 「本机创作数据自 since 后有改动」判定(**云端三态专用**,与备份定时器的 `hasFileChangesSince`
780
+ * 口径刻意不同——见 `docs/design/40-cloud-sync.md` §3):
781
+ *
782
+ * - `project.json` / `outline.json` / `references/` / `sessions/`(含两目录自身 mtime):
783
+ * **严格** `mtime > since`。这些文件不会被备份/同步管道写入,容差在这里只有代价:
784
+ * `lastSyncAt` 是**不推进的固定基准**(只在下一次同步时前移),容差会把 `[since, since+1s]`
785
+ * 内的本机改动**永久漏判**(状态误报「已同步」,用户可能因此拉取覆盖它)。
786
+ * - `data.db` / `data.db-wal`:保留 `BACKUP_CHANGE_TOLERANCE_MS` 容差——备份/推送管道内的
787
+ * `wal_checkpoint` 会把 `data.db` mtime 刷到同步时刻,严格比较会自激误判「永远有改动」。
788
+ */
789
+ export function hasLocalEditsSince(project, since) {
790
+ return hasChangesSince(project, since, { dirs: PACKED_DIR_NAMES });
791
+ }
792
+ /**
793
+ * 「本机**创作数据**自 since 后有改动」判定(**自动推送专用**,卡 7;`docs/design/40-cloud-sync.md` §5):
794
+ * 与 `hasLocalEditsSince` 同一套 mtime 口径,但 `sessions/` **不参与**(会话是聊天产物,纯聊天时段
795
+ * 不该单独烧一次云盘配额——聊天记录会随下一次创作变更的 zip 一起上云,zip 永远含 `sessions/`),
796
+ * 并**纳入 `AGENTS.md`**(项目规则也是创作数据,改规则值得推一次)。
797
+ */
798
+ export function hasAuthoringChangesSince(project, since) {
799
+ return hasChangesSince(project, since, { dirs: [REFERENCE_DIR_NAME], extraFiles: [AGENTS_FILE_NAME] });
800
+ }
801
+ /**
802
+ * 最新一份**可解析**备份的时间(旧命名不算;无 → null)。`hasUnbackedChanges` 的基准,
803
+ * 也用于「升级兜底后是否已有新格式档」的判断。
804
+ */
805
+ export function latestParseableBackupTime(project) {
806
+ return latestBackupTime(join(project.root, BACKUPS_DIR_NAME));
807
+ }
808
+ /**
809
+ * 「本机有改动未进最新备份」(卡 B,`GET /cloud/status` 的 `local.backupStale`):
810
+ * 最新一份本地备份的时间早于最新创作改动(三文件 + `references/` + `sessions/`,口径同
811
+ * `hasLocalEditsSince`,`data.db`/`-wal` 带 1s 容差)。**没有任何可解析备份 → true**
812
+ *(那时推送会 404「没有可推送的备份」,语义上也是「还没有能代表当前的档」)。
813
+ *
814
+ * 用途:自动推送的守卫(有未备份改动时跳过,不做「按需备份」——备份归本地、云端只是镜像)。
815
+ */
816
+ export function hasUnbackedChanges(project) {
817
+ const latest = latestParseableBackupTime(project);
818
+ if (latest === null)
819
+ return true;
820
+ return hasLocalEditsSince(project, latest);
821
+ }
587
822
  /**
588
823
  * 自动备份单次检查(定时器 tick 核心,纯同步、可单测):
589
824
  *
@@ -609,11 +844,22 @@ export function maybeAutoBackup(project) {
609
844
  // ============ 定时器调度(跟随当前项目生命周期,middleware setCurrentProject 挂载) ============
610
845
  /** 当前排程的定时器句柄(null = 未排程/已停止) */
611
846
  let backupTimer = null;
847
+ /** 已注册的 tick 钩子(缺省空 = 仅备份行为,与卡 7 之前一致) */
848
+ let tickHooks = {};
849
+ /**
850
+ * 注册 tick 钩子(`middleware/project.ts` 启动时调用一次;**整体替换**,测试可注入小间隔)。
851
+ * 用模块级注册而不是每次 `startAutoBackup` 传参:`overwriteProjectFiles` 尾部那次重启
852
+ *(restore / 导入 / 云拉取后)也必须带上同一套钩子。
853
+ */
854
+ export function setProjectTick(hooks) {
855
+ tickHooks = hooks;
856
+ }
612
857
  /**
613
858
  * 启动/重启自动备份调度(open/切换项目时调用;restore 后频率可能变化也调用):
614
- * 按当前项目频率 setTimeout 链——每 tick 检查「有变更才备份」后按最新频率重新排程
859
+ * 按当前项目频率 setTimeout 链——每 tick 检查「有变更才备份」后按最新间隔重新排程
615
860
  * (tick 内重读 config,restore 改变频率无需显式通知)。
616
- * 频率关闭(null/0/非枚举)→ 不排程(等价停止)。
861
+ * 频率关闭(null/0/非枚举)→ 回落到 `tickHooks.fallbackIntervalMs()`(自动推送的 2h 兜底排程);
862
+ * 两者皆空 → 不排程(等价停止)。
617
863
  */
618
864
  export function startAutoBackup(project) {
619
865
  stopAutoBackup();
@@ -626,10 +872,14 @@ export function stopAutoBackup() {
626
872
  backupTimer = null;
627
873
  }
628
874
  }
629
- /** 按项目当前频率排程下一次检查;频率关闭 → 不排程 */
875
+ /**
876
+ * 按项目当前间隔排程下一次检查:
877
+ * 备份频率开启 → `freq * 60_000`;关闭 → `tickHooks.fallbackIntervalMs()`(null = 不排程)。
878
+ */
630
879
  function scheduleNext(project) {
631
880
  const freq = resolveBackupFrequency(project.config.backup_frequency_minutes);
632
- if (freq === null)
881
+ const intervalMs = freq === null ? (tickHooks.fallbackIntervalMs?.() ?? null) : freq * 60_000;
882
+ if (intervalMs === null)
633
883
  return;
634
884
  backupTimer = setTimeout(() => {
635
885
  backupTimer = null;
@@ -639,8 +889,14 @@ function scheduleNext(project) {
639
889
  catch (err) {
640
890
  console.error("[backup] 自动备份检查失败:", err);
641
891
  }
642
- scheduleNext(project); // 每次 tick 重读频率(restore/切换可能改变)
643
- }, freq * 60_000);
892
+ try {
893
+ tickHooks.onTick?.(project); // 钩子抛错同样不中断调度(自动推送的失败在钩子内部自记状态)
894
+ }
895
+ catch (err) {
896
+ console.error("[backup] 定时器钩子失败:", err);
897
+ }
898
+ scheduleNext(project); // 每次 tick 重读频率与钩子(restore/切换/云端配置可能改变)
899
+ }, intervalMs);
644
900
  // unref:定时器不阻止进程退出(测试/服务关闭后无残留句柄)
645
901
  backupTimer.unref?.();
646
902
  }
@@ -663,6 +919,7 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
663
919
  // (校验已通过,文件内容本身有效,部分替换可经重新恢复修复);replaced 清单供失败日志使用(P1-2)
664
920
  const targetNames = [PROJECT_FILE_NAME, OUTLINE_FILE_NAME, DATA_DB_FILE_NAME];
665
921
  const replaced = [];
922
+ let merged;
666
923
  try {
667
924
  const parsedProject = JSON.parse(new TextDecoder().decode(entries[PROJECT_FILE_NAME]));
668
925
  const nextProject = {
@@ -676,10 +933,21 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
676
933
  replaced.push(OUTLINE_FILE_NAME);
677
934
  writeFileAtomic(join(dir, DATA_DB_FILE_NAME), entries[DATA_DB_FILE_NAME]);
678
935
  replaced.push(DATA_DB_FILE_NAME);
679
- // 打包目录整体覆盖(references/ 含 .trash/、sessions/ 会话 JSONL)——
680
- // 恢复/导入是「整体还原」语义,目录以备份内容为准,本地残留不混入
681
- for (const dirName of PACKED_DIR_NAMES)
682
- overwriteDirFromEntries(dir, dirName, entries);
936
+ // 打包目录(references/ 含 .trash/、sessions/ 会话 JSONL):
937
+ // - 默认「整体覆盖」——恢复/导入是「整体还原」语义,目录以备份内容为准,本地残留不混入
938
+ // - `mergePackedDirs`(云拉取)= 与基线做三方比较的**并集合并**(删除优先,本机新增保留)
939
+ for (const dirName of PACKED_DIR_NAMES) {
940
+ if (opts.mergePackedDirs === undefined) {
941
+ overwriteDirFromEntries(dir, dirName, entries);
942
+ }
943
+ else {
944
+ const counts = mergeDirFromEntries(dir, dirName, entries, opts.mergePackedDirs.baseEntries);
945
+ merged = merged ?? { kept: 0, written: 0, removed: 0 };
946
+ merged.kept += counts.kept;
947
+ merged.written += counts.written;
948
+ merged.removed += counts.removed;
949
+ }
950
+ }
683
951
  }
684
952
  catch (err) {
685
953
  // P1-2:失败路径日志(对齐「记日志暴露部分替换」承诺)——已替换/未替换文件清单 + 覆盖前快照名
@@ -687,6 +955,7 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
687
955
  console.error(`[backup] 覆盖文件替换失败(已替换: ${replaced.join(", ") || "无"};未替换: ${notReplaced.join(", ") || "无"}${opts.snapshotFileName !== undefined ? `;覆盖前快照: ${opts.snapshotFileName}` : ""})`, err);
688
956
  throw err;
689
957
  }
958
+ return merged;
690
959
  }
691
960
  /**
692
961
  * 自动迁移:project.json 存在非空 `prompt` 且项目目录无 →
@@ -739,6 +1008,7 @@ export function migratePromptToAgents(project) {
739
1008
  export function overwriteProjectFiles(project, entries, opts = {}) {
740
1009
  const dbPath = join(project.root, DATA_DB_FILE_NAME);
741
1010
  closeDatabase(project.db); // 释放当前连接(替换 data.db 前必须;替换失败恢复见 catch)
1011
+ let merged;
742
1012
  try {
743
1013
  // 清理陈旧 WAL/SHM 残留(正常关闭通常已清理;防御:替换后新库不得复用旧 WAL)
744
1014
  for (const suffix of ["-wal", "-shm"]) {
@@ -750,10 +1020,11 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
750
1020
  throw err;
751
1021
  }
752
1022
  }
753
- writeProjectFilesFromBackup(project.root, entries, {
1023
+ merged = writeProjectFilesFromBackup(project.root, entries, {
754
1024
  keepId: project.config.id,
755
1025
  ...(opts.name !== undefined ? { name: opts.name } : {}),
756
1026
  ...(opts.snapshotFileName !== undefined ? { snapshotFileName: opts.snapshotFileName } : {}),
1027
+ ...(opts.mergePackedDirs !== undefined ? { mergePackedDirs: opts.mergePackedDirs } : {}),
757
1028
  });
758
1029
  // 重连 + 版本对齐
759
1030
  let active = openDatabase(dbPath);
@@ -787,6 +1058,7 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
787
1058
  throw err;
788
1059
  }
789
1060
  startAutoBackup(project); // 重启定时器(覆盖包内频率可能不同)
1061
+ return merged;
790
1062
  }
791
1063
  /**
792
1064
  * 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,「覆盖前自动快照」):
@@ -845,10 +1117,10 @@ function writeFileAtomic(filePath, data) {
845
1117
  /**
846
1118
  * 从备份恢复当前项目(POST /project/backup/restore):
847
1119
  *
848
- * 1. fileName 白名单校验:仅允许 `.backups/` 下时间戳格式(兼容四类:
849
- * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip` 毫秒级(含 kind 段)/ `<YYYYMMDD-HHmmssSSS>-<名称>.zip`
850
- * 旧带名称 / 旧秒级 `<YYYYMMDD-HHmmss>.zip`——shared parseBackupFileName,^$ 锚定 + 名称部分
851
- * 拒绝 /\\,天然防路径分隔符与 `..` 穿越);格式合法但文件不存在 → 404
1120
+ * 1. fileName 白名单校验:仅允许 `.backups/` 下的**唯一命名格式**
1121
+ * `<YYYYMMDD-HHmmssSSS>-<自动|手动>-<设备>[-<标签>]-人物N-设定N-章N.zip`(shared parseBackupFileName,
1122
+ * ^$ 锚定 + 设备/标签部分拒绝 /\\,天然防路径分隔符与 `..` 穿越;旧命名不可解析 ⇒ 一并被拒);
1123
+ * 格式合法但文件不存在 → 404
852
1124
  * 2. **覆盖前自动快照**:当前三文件打包为快照存入 .backups/(复用备份管道,
853
1125
  * 就是普通备份文件,自然参与保留策略——后悔药)
854
1126
  * 3. 备份包校验(validateBackupPackage:zip/白名单//user_version 三态,
@@ -861,9 +1133,9 @@ function writeFileAtomic(filePath, data) {
861
1133
  *
862
1134
  * @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
863
1135
  */
864
- export function restoreBackup(project, fileName) {
1136
+ export function restoreBackup(project, fileName, options = {}) {
865
1137
  // 1. 白名单校验(assertBackupFileNameFormat:parseBackupFileName 全格式校验,防路径穿越;
866
- // 兼容毫秒级/带 kind 段/旧带名称/旧秒级文件名)
1138
+ // 旧命名不可解析 一并被拒)
867
1139
  assertBackupFileNameFormat(fileName);
868
1140
  const backupPath = join(project.root, BACKUPS_DIR_NAME, fileName);
869
1141
  if (!existsSync(backupPath)) {
@@ -878,11 +1150,19 @@ export function restoreBackup(project, fileName) {
878
1150
  // 重连 data.db + 同步 config + 重启定时器。
879
1151
  // name 归一为当前目录名(审核裁决:与 import 覆盖一致,维持「目录名 = 书名」
880
1152
  // 不变式——id 是身份、name 是展示名;改名需求走 /project/rename)
881
- overwriteProjectFiles(project, entries, { name: basename(project.root), snapshotFileName: snapshot.fileName });
1153
+ const merged = overwriteProjectFiles(project, entries, {
1154
+ name: basename(project.root),
1155
+ snapshotFileName: snapshot.fileName,
1156
+ ...(options.mergePackedDirs !== undefined ? { mergePackedDirs: options.mergePackedDirs } : {}),
1157
+ });
882
1158
  // 5. 会话无归属迁移:对话历史已出库为项目目录内 `sessions/*.jsonl`(迁移 006),归属由
883
1159
  // 目录表达,不再依赖 data.db 的 project_id——恢复只需覆盖三文件 + references/
884
1160
  // (`sessions/` 的入包与覆盖语义由备份管道承担,见 createBackupZip / writeProjectFilesFromBackup)。
885
1161
  //:snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
886
- return { snapshot: { fileName: snapshot.fileName, createdAt: snapshot.createdAt } };
1162
+ // snapshot 仅含 fileName/createdAt(size 属内部信息不暴露);merged 仅在并集合并模式下有值
1163
+ return {
1164
+ snapshot: { fileName: snapshot.fileName, createdAt: snapshot.createdAt },
1165
+ ...(merged !== undefined ? { merged } : {}),
1166
+ };
887
1167
  }
888
1168
  //# sourceMappingURL=backup.js.map