@whispering233/ai-editor-server 0.0.21 → 0.0.29

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 (70) hide show
  1. package/client-dist/assets/index-0zEqGbv5.css +1 -0
  2. package/client-dist/assets/index-DMtHdMMq.js +521 -0
  3. package/client-dist/index.html +3 -3
  4. package/dist/backup.d.ts +78 -78
  5. package/dist/backup.d.ts.map +1 -1
  6. package/dist/backup.js +138 -139
  7. package/dist/backup.js.map +1 -1
  8. package/dist/consistency.d.ts +3 -3
  9. package/dist/consistency.d.ts.map +1 -1
  10. package/dist/consistency.js +4 -4
  11. package/dist/consistency.js.map +1 -1
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +7 -7
  14. package/dist/debug.js.map +1 -1
  15. package/dist/index.d.ts +9 -9
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +37 -37
  18. package/dist/index.js.map +1 -1
  19. package/dist/middleware/error.d.ts +4 -4
  20. package/dist/middleware/error.d.ts.map +1 -1
  21. package/dist/middleware/error.js +7 -9
  22. package/dist/middleware/error.js.map +1 -1
  23. package/dist/middleware/project.d.ts +9 -9
  24. package/dist/middleware/project.d.ts.map +1 -1
  25. package/dist/middleware/project.js +23 -23
  26. package/dist/middleware/project.js.map +1 -1
  27. package/dist/reference-files.d.ts +12 -12
  28. package/dist/reference-files.d.ts.map +1 -1
  29. package/dist/reference-files.js +20 -21
  30. package/dist/reference-files.js.map +1 -1
  31. package/dist/routes/chat.d.ts +18 -18
  32. package/dist/routes/chat.d.ts.map +1 -1
  33. package/dist/routes/chat.js +70 -83
  34. package/dist/routes/chat.js.map +1 -1
  35. package/dist/routes/delta.d.ts.map +1 -1
  36. package/dist/routes/delta.js +27 -28
  37. package/dist/routes/delta.js.map +1 -1
  38. package/dist/routes/entity.d.ts.map +1 -1
  39. package/dist/routes/entity.js +44 -45
  40. package/dist/routes/entity.js.map +1 -1
  41. package/dist/routes/names.d.ts.map +1 -1
  42. package/dist/routes/names.js +8 -9
  43. package/dist/routes/names.js.map +1 -1
  44. package/dist/routes/outline.d.ts +1 -1
  45. package/dist/routes/outline.d.ts.map +1 -1
  46. package/dist/routes/outline.js +23 -24
  47. package/dist/routes/outline.js.map +1 -1
  48. package/dist/routes/project.d.ts +6 -6
  49. package/dist/routes/project.d.ts.map +1 -1
  50. package/dist/routes/project.js +91 -95
  51. package/dist/routes/project.js.map +1 -1
  52. package/dist/routes/proposal.d.ts.map +1 -1
  53. package/dist/routes/proposal.js +40 -42
  54. package/dist/routes/proposal.js.map +1 -1
  55. package/dist/routes/reference.d.ts.map +1 -1
  56. package/dist/routes/reference.js +1 -2
  57. package/dist/routes/reference.js.map +1 -1
  58. package/dist/routes/relation.d.ts.map +1 -1
  59. package/dist/routes/relation.js +14 -15
  60. package/dist/routes/relation.js.map +1 -1
  61. package/dist/routes/settings.d.ts +35 -17
  62. package/dist/routes/settings.d.ts.map +1 -1
  63. package/dist/routes/settings.js +157 -52
  64. package/dist/routes/settings.js.map +1 -1
  65. package/dist/routes/trash.d.ts.map +1 -1
  66. package/dist/routes/trash.js +15 -16
  67. package/dist/routes/trash.js.map +1 -1
  68. package/package.json +7 -7
  69. package/client-dist/assets/index-B51Kcb1K.css +0 -1
  70. package/client-dist/assets/index-zgl3whdc.js +0 -104
package/dist/backup.js CHANGED
@@ -1,19 +1,18 @@
1
- // 自动备份与恢复(B2.2 决策 27 + B2.5 决策 28 + B2.6 决策 29
1
+ // 自动备份与恢复(B2.2 + B2.5 + B2.6 )
2
2
  //
3
- // 单一事实来源:doc/design/decisions.md 决策 27/28/29、doc/api/endpoints.md「备份管理」节。
4
3
  // 职责:
5
- // - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip
6
- // (复用 E1 打包;决策 28 毫秒精度;决策 29 kind 段:手动备份落 -m[-<名称>]、
7
- // 自动备份/覆盖前快照纯时间戳、重命名后自动备份落 -a-<名称>)
8
- // - 保留策略:每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧(含覆盖前快照;
9
- // 新旧格式文件名均参与——parseBackupFileName 兼容解析)
10
- // - 自动定时器:跟随当前项目生命周期(middleware/project.ts setCurrentProject 挂载启停),
11
- // 有变更才备份(三文件 + data.db-wal 伴生文件 mtime 与 .backups/ 最新备份时间比较,
12
- // 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db 变更)
13
- // - 备份包校验(restore 与 import 共用):zip 解析/白名单/三文件齐全/顶层契约/
14
- // data.db user_version 三态分流(E4/E5,绝不静默重建)
15
- // - restore:fileName 白名单 → 覆盖前自动快照 → 校验 → 原子替换三文件
16
- // - rename:只改名称段(时间戳与 kind 保持,决策 29)
4
+ // - 备份管道:三文件 + wal_checkpoint → .backups/<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip
5
+ // (复用导出打包;毫秒精度;kind 段:手动备份落 -m[-<名称>]、
6
+ // 自动备份/覆盖前快照纯时间戳、重命名后自动备份落 -a-<名称>)
7
+ // - 保留策略:每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧(含覆盖前快照;
8
+ // 新旧格式文件名均参与——parseBackupFileName 兼容解析)
9
+ // - 自动定时器:跟随当前项目生命周期(middleware/project.ts setCurrentProject 挂载启停),
10
+ // 有变更才备份(三文件 + data.db-wal 伴生文件 mtime 与 .backups/ 最新备份时间比较,
11
+ // 无状态、服务重启不丢;F2:WAL 模式写只刷新 -wal,补查避免漏检 data.db 变更)
12
+ // - 备份包校验(restore 与 import 共用):zip 解析/白名单/三文件齐全/顶层/
13
+ // data.db user_version 三态分流(绝不静默重建)
14
+ // - restore:fileName 白名单 → 覆盖前自动快照 → 校验 → 原子替换三文件
15
+ // - rename:只改名称段(时间戳与 kind 保持)
17
16
  //
18
17
  // 依赖方向:本模块不依赖 middleware(避免循环依赖——定时器持有所调度项目引用,
19
18
  // 由 setCurrentProject 显式启停);仅 import type ProjectContext(类型擦除)。
@@ -25,11 +24,11 @@ import { BACKUP_FREQUENCIES, DEFAULT_BACKUP_FREQUENCY_MINUTES, formatBackupFileN
25
24
  import { PROJECT_EXPORT_FILE_NAMES } from "@whispering233/ai-editor-shared/schemas";
26
25
  import { closeDatabase, checkpointWal, DATA_DB_FILE_NAME, ensureSchemaCompatible, getUserVersion, hasMigrationPath, migrateChatMessagesProject, openDatabase, OUTLINE_FILE_NAME, PROJECT_FILE_NAME, readAgentsFile, readProjectFile, SCHEMA_VERSION, writeAgentsFile, } from "@whispering233/ai-editor-db";
27
26
  import { HttpError } from "./middleware/error.js";
28
- /** 备份目录名(决策 27:项目目录内 .backups/,随书籍移动自然携带) */
27
+ /** 备份目录名(项目目录内 .backups/,随书籍移动自然携带) */
29
28
  export const BACKUPS_DIR_NAME = ".backups";
30
- /** 参考资料目录名(决策 43:项目目录内 references/,随备份 zip 打包) */
29
+ /** 参考资料目录名(项目目录内 references/,随备份 zip 打包) */
31
30
  const REFERENCE_DIR_NAME = "references";
32
- /** 备份包条目白名单判定(决策 43 扩展:三文件 + references/ 前缀——逐名比对天然防 zip 路径穿越;
31
+ /** 备份包条目白名单判定(三文件 + references/ 前缀——逐名比对天然防 zip 路径穿越;
33
32
  * references/ 子路径拒绝含 `..` 的条目防相对路径逃逸) */
34
33
  export function isAllowedBackupEntry(name) {
35
34
  if (PROJECT_EXPORT_FILE_NAMES.includes(name))
@@ -59,7 +58,7 @@ function listReferenceDirFiles(root) {
59
58
  const MAX_UNZIP_BUDGET = 200 * 1024 * 1024;
60
59
  /**
61
60
  * mtime 变更判定容差(毫秒):备份管道内 wal_checkpoint 会把 WAL 合并回 data.db 主文件,
62
- * 其 mtime 刷新到「备份时刻」(毫秒精度)。决策 28 起文件名时间戳为毫秒精度,
61
+ * 其 mtime 刷新到「备份时刻」(毫秒精度)。 起文件名时间戳为毫秒精度,
63
62
  * 文件名截断误差已消除——但容差**保留 1s**:粗粒度 mtime 文件系统(如 FAT/exFAT 2s 粒度)
64
63
  * 下,严格 `mtime > lastBackupAt` 仍可能误判,1s 容差是必要防御;最小 tick 5 分钟,
65
64
  * 用户变更必然超出容差窗口。
@@ -80,12 +79,12 @@ function assertBackupFileNameFormat(fileName) {
80
79
  }
81
80
  return parsed;
82
81
  }
83
- // ============ 备份管道(复用 E1 打包,避免复制) ============
82
+ // ============ 备份管道(复用 打包,避免复制) ============
84
83
  /**
85
- * 打包当前项目为 zip(E1 export 同款管道;决策 43 扩展):
84
+ * 打包当前项目为 zip( export 同款管道):
86
85
  * wal_checkpoint(TRUNCATE) 把 WAL 合并回主文件(zip 内 data.db 为完整快照,
87
86
  * 无需附带 -wal/-shm)→ zipSync 打包(键序稳定:project.json → outline.json → data.db
88
- * → references/**(决策 43:参考资料目录含 .trash/ 随包——项目自包含))。
87
+ * → references/**(参考资料目录含 .trash/ 随包——项目自包含))。
89
88
  * 三文件缺失任一 → 抛错(打开的项目三文件必然齐全,缺失即损坏,不导出半成品包)。
90
89
  */
91
90
  export function createBackupZip(project) {
@@ -96,23 +95,23 @@ export function createBackupZip(project) {
96
95
  [OUTLINE_FILE_NAME]: readFileSync(join(dir, OUTLINE_FILE_NAME)),
97
96
  [DATA_DB_FILE_NAME]: readFileSync(join(dir, DATA_DB_FILE_NAME)),
98
97
  };
99
- // 决策 43:references/ 目录随包(存在则递归打包;不存在跳过——旧项目无目录不报错)
98
+ // references/ 目录随包(存在则递归打包;不存在跳过——旧项目无目录不报错)
100
99
  for (const rel of listReferenceDirFiles(dir)) {
101
100
  zipEntries[`${REFERENCE_DIR_NAME}/${rel}`] = readFileSync(join(dir, REFERENCE_DIR_NAME, rel));
102
101
  }
103
102
  return zipSync(zipEntries, { level: 6 });
104
103
  }
105
104
  /**
106
- * 生成不冲突的备份文件名:`<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`(决策 28 毫秒精度 +
107
- * 决策 29 kind 段;shared formatBackupFileName;kind 缺省 auto——纯时间戳;带自定义名称 →
105
+ * 生成不冲突的备份文件名:`<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`( 毫秒精度 +
106
+ * kind 段;shared formatBackupFileName;kind 缺省 auto——纯时间戳;带自定义名称 →
108
107
  * `-a-<名称>`(auto)/ `-m-<名称>`(manual))。
109
108
  *
110
109
  * 同毫秒冲突(如「立即备份 + restore 覆盖前快照」连续触发,理论罕见)处理:
111
- * 时间戳 +1 毫秒循环去重,**保持 <YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip 格式契约**——
110
+ * 时间戳 +1 毫秒循环去重,**保持 <YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip 格式**——
112
111
  * parseBackupFileName 解析与 restore 白名单校验不受影响。
113
112
  *
114
113
  * @returns { fileName, date }——date 为最终去重后的时间戳(与 fileName 时间戳段一致,
115
- * 调用方直接用于构造 createdAt,免 parse 回读)
114
+ * 调用方直接用于构造 createdAt,免 parse 回读)
116
115
  */
117
116
  function uniqueBackupFileName(backupsDir, date, opts) {
118
117
  const kind = opts?.kind ?? "auto";
@@ -128,14 +127,14 @@ function uniqueBackupFileName(backupsDir, date, opts) {
128
127
  * 立即备份当前项目(手动触发 / 自动定时器 / restore 覆盖前快照共用):
129
128
  * 打包 → 写入 .backups/<时间戳>[-<kind>][-<名称>].zip(毫秒精度;同毫秒 +1ms 去重)→
130
129
  * 触发保留策略清理(失败不阻塞)。
131
- * 写盘失败向上抛(errorHandler → 500 INTERNAL_ERROR,endpoints.md POST /backup 语义)。
130
+ * 写盘失败向上抛(errorHandler → 500 INTERNAL_ERROR, POST /backup 语义)。
132
131
  *
133
- * @param opts.kind 备份类型(决策 29,缺省 "auto"):手动触发传 "manual"(文件名落 -m 段);
134
- * 自动备份/覆盖前快照不传(auto,纯时间戳)
135
- * @param opts.name 手动备份自定义名称(决策 28,仅带名称的手动/重命名场景传入):
136
- * sanitizeBackupName 是名称校验/规范化**唯一执行点**——非法(含路径分隔符/超长/纯点)→
137
- * 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name,文件名保持纯时间戳。
138
- * 注意:name 仅在 opts.name !== undefined 且 sanitize 通过后传入 formatBackupFileName。
132
+ * @param opts.kind 备份类型(缺省 "auto"):手动触发传 "manual"(文件名落 -m 段);
133
+ * 自动备份/覆盖前快照不传(auto,纯时间戳)
134
+ * @param opts.name 手动备份自定义名称(仅带名称的手动/重命名场景传入):
135
+ * sanitizeBackupName 是名称校验/规范化**唯一执行点**——非法(含路径分隔符/超长/纯点)→
136
+ * 400 VALIDATION_ERROR;自动备份/覆盖前快照不传 name,文件名保持纯时间戳。
137
+ * 注意:name 仅在 opts.name !== undefined 且 sanitize 通过后传入 formatBackupFileName。
139
138
  */
140
139
  export function writeBackup(project, opts) {
141
140
  const kind = opts?.kind ?? "auto";
@@ -156,43 +155,43 @@ export function writeBackup(project, opts) {
156
155
  return {
157
156
  fileName,
158
157
  size: zip.length,
159
- createdAt: toIso(date), // date = 最终去重后的时间戳,与 fileName 时间戳段一致(决策 27 无状态语义)
158
+ createdAt: toIso(date), // date = 最终去重后的时间戳,与 fileName 时间戳段一致( 无状态语义)
160
159
  kind,
161
160
  ...(name !== undefined ? { name } : {}),
162
161
  };
163
162
  }
164
163
  /**
165
- * 重命名备份(POST /project/backup/rename,决策 29):**只改名称段**,时间戳与 kind 保持。
164
+ * 重命名备份(POST /project/backup/rename):**只改名称段**,时间戳与 kind 保持。
165
+ *
166
166
  *
167
- * 契约:
168
167
  * 1. fileName 白名单校验:仅接受 parseBackupFileName 可解析的时间戳格式
169
- * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`(含旧格式兼容,^$ 锚定 + 名称部分拒绝 /\\,
170
- * 天然防路径分隔符与 `..` 穿越)→ 非法 400 VALIDATION_ERROR
168
+ * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip`(含旧格式兼容,^$ 锚定 + 名称部分拒绝 /\\,
169
+ * 天然防路径分隔符与 `..` 穿越)→ 非法 400 VALIDATION_ERROR
171
170
  * 2. 文件不存在 → 404 VALIDATION_ERROR
172
- * 3. 新名称解析(决策 29):请求未传 name → 清除名称段;传空串/纯空白 → 清除名称段;
173
- * 非空 → sanitizeBackupName 规范化(非法 → 400 VALIDATION_ERROR,文案同 writeBackup)
171
+ * 3. 新名称解析:请求未传 name → 清除名称段;传空串/纯空白 → 清除名称段;
172
+ * 非空 → sanitizeBackupName 规范化(非法 → 400 VALIDATION_ERROR,文案同 writeBackup)
174
173
  * 4. 新文件名 = formatBackupFileName(parsed.time, { kind: parsed.kind, name: 新名称 })——
175
- * kind 不随重命名改变(auto 重命名后仍落 -a- 段、manual 仍落 -m- 段)
174
+ * kind 不随重命名改变(auto 重命名后仍落 -a- 段、manual 仍落 -m- 段)
176
175
  * 5. 幂等:新文件名 === 原文件名(如重命名为相同名称)→ 不移动文件,直接返回当前条目
177
- * (重新 stat 取 size;stat 失败 → 404「备份不存在」)
178
- * 6. **目标冲突防御(oracle P1-1,决策 29)**:新文件名已存在(≠ 原文件)→ 409
179
- * BACKUP_TARGET_EXISTS——POSIX rename 目标存在时静默替换,可达路径:旧秒级改名后毫秒补
180
- * 000 撞上毫秒为 0 的自动备份、同毫秒双 manual(T-m.zip + T-m-X.zip)清名覆盖;显式拒绝
176
+ * (重新 stat 取 size;stat 失败 → 404「备份不存在」)
177
+ * 6. **目标冲突防御(oracle P1-1)**:新文件名已存在(≠ 原文件)→ 409
178
+ * BACKUP_TARGET_EXISTS——POSIX rename 目标存在时静默替换,可达路径:旧秒级改名后毫秒补
179
+ * 000 撞上毫秒为 0 的自动备份、同毫秒双 manual(T-m.zip + T-m-X.zip)清名覆盖;显式拒绝
181
180
  * 7. renameSync 同目录原子改名(失败向上抛 → 500);改名后统一 stat 取 size
182
- * (改名瞬间被删等竞态 → 404「备份不存在」)
181
+ * (改名瞬间被删等竞态 → 404「备份不存在」)
183
182
  *
184
183
  * @throws HttpError 400(文件名非法/名称非法)、404(备份不存在)、409 BACKUP_TARGET_EXISTS
185
184
  */
186
185
  export function renameBackup(project, fileName, name) {
187
186
  // 1. 白名单校验(assertBackupFileNameFormat:parseBackupFileName 全格式校验,防路径穿越;
188
- // 非法 → 400;返回解析结果直接取 time/kind)
187
+ // 非法 → 400;返回解析结果直接取 time/kind)
189
188
  const parsed = assertBackupFileNameFormat(fileName);
190
189
  const backupsDir = join(project.root, BACKUPS_DIR_NAME);
191
190
  if (!existsSync(join(backupsDir, fileName))) {
192
191
  throw new HttpError(404, "VALIDATION_ERROR", `备份不存在: ${fileName}`);
193
192
  }
194
193
  // 2. 新名称解析:请求未传 name → 清除名称;传空串/纯空白 → 清除名称(sanitize null 且 trim 空);
195
- // 非空 → sanitize 规范化(null 且 trim 非空 → 400,文案同 writeBackup)
194
+ // 非空 → sanitize 规范化(null 且 trim 非空 → 400,文案同 writeBackup)
196
195
  let nextName;
197
196
  if (name !== undefined) {
198
197
  const sanitized = sanitizeBackupName(name);
@@ -202,7 +201,7 @@ export function renameBackup(project, fileName, name) {
202
201
  if (sanitized !== null)
203
202
  nextName = sanitized;
204
203
  }
205
- // 3. 新文件名(时间戳与 kind 保持原备份——kind 不随重命名改变,决策 29)
204
+ // 3. 新文件名(时间戳与 kind 保持原备份——kind 不随重命名改变)
206
205
  const newFileName = formatBackupFileName(parsed.time, { kind: parsed.kind, name: nextName });
207
206
  // 4. 幂等:名称未变 → 不移动文件,返回当前条目(重新 stat 取 size;读不到 → 404)
208
207
  if (newFileName === fileName) {
@@ -219,9 +218,9 @@ export function renameBackup(project, fileName, name) {
219
218
  throw new HttpError(404, "VALIDATION_ERROR", `备份不存在: ${fileName}`);
220
219
  }
221
220
  }
222
- // 5. 目标冲突防御(oracle P1-1,决策 29):POSIX rename 目标存在时静默替换(数据丢失风险)——
223
- // 可达路径:旧秒级改名后毫秒补 000 撞上毫秒为 0 的自动备份、同毫秒双 manual 清名覆盖等;
224
- // 显式 409 拒绝。幂等分支(target === fileName)已在第 4 步提前返回,此处必然 target ≠ fileName。
221
+ // 5. 目标冲突防御(oracle P1-1):POSIX rename 目标存在时静默替换(数据丢失风险)——
222
+ // 可达路径:旧秒级改名后毫秒补 000 撞上毫秒为 0 的自动备份、同毫秒双 manual 清名覆盖等;
223
+ // 显式 409 拒绝。幂等分支(target === fileName)已在第 4 步提前返回,此处必然 target ≠ fileName。
225
224
  if (existsSync(join(backupsDir, newFileName))) {
226
225
  throw new HttpError(409, "BACKUP_TARGET_EXISTS", `目标备份文件名已存在: ${newFileName}`);
227
226
  }
@@ -244,9 +243,9 @@ export function renameBackup(project, fileName, name) {
244
243
  };
245
244
  }
246
245
  /**
247
- * 保留策略(决策 27):每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧。
246
+ * 保留策略:每项目保留最近 MAX_BACKUPS_PER_PROJECT 份,超出删除最旧。
248
247
  * 排序依据 = 文件名时间戳(可解析的才参与;非法文件名不参与保留判定)。
249
- * 清理失败不阻塞调用方(记日志即可,决策 27「清理失败不阻塞备份主流程」)。
248
+ * 清理失败不阻塞调用方(记日志即可,「清理失败不阻塞备份主流程」)。
250
249
  */
251
250
  export function pruneBackups(backupsDir) {
252
251
  let files;
@@ -319,7 +318,7 @@ function concatChunks(chunks, totalSize) {
319
318
  return out;
320
319
  }
321
320
  /**
322
- * 带预算的 zip 解压(ora-4 zip 炸弹防御,自 E2 import 提取):
321
+ * 带预算的 zip 解压(ora-4 zip 炸弹防御,自 import 提取):
323
322
  * fflate Unzip 流式解压(同步 push)——onfile 回调中**只解压白名单条目**(未知条目
324
323
  * 仅记名不 start,省预算;白名单检查由调用方用 names 列表执行),ondata 累计解压总字节,
325
324
  * 超过预算抛 HttpError 中止。返回 { entries(白名单条目解压结果), names(全部条目名)}。
@@ -331,7 +330,7 @@ function unzipWithBudget(zipData, budget) {
331
330
  const unzipper = new Unzip((file) => {
332
331
  names.push(file.name);
333
332
  if (!isAllowedBackupEntry(file.name))
334
- return; // 未知条目不解压(白名单 = 三文件 + references/,决策 43)
333
+ return; // 未知条目不解压(白名单 = 三文件 + references/)
335
334
  const chunks = [];
336
335
  let size = 0;
337
336
  file.ondata = (err, chunk, final) => {
@@ -348,8 +347,8 @@ function unzipWithBudget(zipData, budget) {
348
347
  };
349
348
  file.start();
350
349
  });
351
- // 关键(fflate API 契约):Unzip 默认仅注册 stored(0) 解码器——deflate(8) 压缩的
352
- // zip(zipSync 默认)必须显式 register(UnzipInflate),否则 start() 报 unknown
350
+ // 关键(fflate API ):Unzip 默认仅注册 stored(0) 解码器——deflate(8) 压缩的
351
+ // zip(zipSync 默认)必须显式 register(UnzipInflate),否则 start 报 unknown
353
352
  // compression type(fflate README 明确要求)
354
353
  unzipper.register(UnzipInflate);
355
354
  unzipper.push(zipData, true);
@@ -360,14 +359,14 @@ function unzipWithBudget(zipData, budget) {
360
359
  }
361
360
  return { entries, names };
362
361
  }
363
- /** project.json 顶层契约最小校验(shared 无文件形态 schema,不扩契约范围) */
362
+ /** project.json 顶层最小校验(shared 无文件形态 schema,不扩范围) */
364
363
  function isValidProjectFile(parsed) {
365
364
  if (typeof parsed !== "object" || parsed === null)
366
365
  return false;
367
366
  const p = parsed;
368
367
  return typeof p.id === "string" && p.id.length > 0 && typeof p.name === "string" && typeof p.schema_version === "number";
369
368
  }
370
- /** outline.json 顶层契约校验(与 db 包 validateOutlineFile 同构:root 根 + schema_version 数字 + children 数组) */
369
+ /** outline.json 顶层校验(与 db 包 validateOutlineFile 同构:root 根 + schema_version 数字 + children 数组) */
371
370
  function isValidOutlineFile(parsed) {
372
371
  if (typeof parsed !== "object" || parsed === null)
373
372
  return false;
@@ -375,26 +374,26 @@ function isValidOutlineFile(parsed) {
375
374
  return p.id === "root" && p.type === "root" && typeof p.schema_version === "number" && Array.isArray(p.children);
376
375
  }
377
376
  /**
378
- * 备份包完整校验(自 E2 import 校验顺序 3-7 提取,restore 与 import 共用):
377
+ * 备份包完整校验(自 import 校验顺序 3-7 提取,restore 与 import 共用):
379
378
  *
380
379
  * 1. zip 解析(流式 + 解压总字节预算 200MB,zip 炸弹防御;失败 400「不是有效的项目备份包」)
381
380
  * 2. 条目白名单(只接受 PROJECT_EXPORT_FILE_NAMES 三文件名,逐名比对天然防 zip 路径穿越)
382
381
  * 3. 三文件齐全
383
- * 4. 临时目录写入 → project.json / outline.json 顶层契约(JSON 损坏 / 契约不符 → 400)
382
+ * 4. 临时目录写入 → project.json / outline.json 顶层(JSON 损坏 / 不符 → 400)
384
383
  * 5. data.db 校验(**大小 > 0 → 打开成功 → user_version**):
385
- * - v === SCHEMA_VERSION → 接受(现状)
386
- * - v < SCHEMA_VERSION → **有迁移路径** → 接受(open/restore 后自动前向迁移,E5);
387
- * **无迁移路径** → 409(文案标注版本过旧无路径)
388
- * - v > SCHEMA_VERSION → 409(E4 语义:备份来自更高版本程序)
389
- * 拒绝均不静默重建——恢复备份须明示版本不兼容。
384
+ * - v === SCHEMA_VERSION → 接受(现状)
385
+ * - v < SCHEMA_VERSION → **有迁移路径** → 接受(open/restore 后自动前向迁移);
386
+ * **无迁移路径** → 409(文案标注版本过旧无路径)
387
+ * - v > SCHEMA_VERSION → 409(备份来自更高版本程序)
388
+ * 拒绝均不静默重建——恢复备份须明示版本不兼容。
390
389
  *
391
390
  * 校验在 mkdtemp 临时目录完成(finally 清理,无论成败);通过后返回解压内容与
392
391
  * project_id(调用方负责搬入/替换)。**校验通过前不触碰目标项目任何数据**(零触碰)。
393
392
  *
394
- * @throws HttpError 400(坏包/缺文件/未知条目/契约不符)/ 409 SCHEMA_VERSION_MISMATCH
393
+ * @throws HttpError 400(坏包/缺文件/未知条目/不符)/ 409 SCHEMA_VERSION_MISMATCH
395
394
  */
396
395
  export function validateBackupPackage(zipData) {
397
- // 1/2. 解压 + 白名单(严格拒绝):三数据文件名 + references/ 前缀(决策 43)
396
+ // 1/2. 解压 + 白名单(严格拒绝):三数据文件名 + references/ 前缀
398
397
  let entries;
399
398
  let entryNames;
400
399
  try {
@@ -421,7 +420,7 @@ export function validateBackupPackage(zipData) {
421
420
  for (const f of PROJECT_EXPORT_FILE_NAMES) {
422
421
  writeFileSync(join(tmpDir, f), entries[f]);
423
422
  }
424
- // project.json 顶层契约(JSON 可解析 + id/name/schema_version)
423
+ // project.json 顶层(JSON 可解析 + id/name/schema_version)
425
424
  let projectConfig;
426
425
  try {
427
426
  projectConfig = JSON.parse(readFileSync(join(tmpDir, PROJECT_FILE_NAME), "utf8"));
@@ -430,13 +429,13 @@ export function validateBackupPackage(zipData) {
430
429
  throw new HttpError(400, "VALIDATION_ERROR", "备份包内 project.json 不是合法 JSON");
431
430
  }
432
431
  if (!isValidProjectFile(projectConfig)) {
433
- throw new HttpError(400, "VALIDATION_ERROR", "备份包内 project.json 顶层契约不符(需 id/name/schema_version)");
432
+ throw new HttpError(400, "VALIDATION_ERROR", "备份包内 project.json 顶层不符(需 id/name/schema_version)");
434
433
  }
435
- // outline.json 顶层契约({id:"root",type:"root",schema_version,children[]})
434
+ // outline.json 顶层({id:"root",type:"root",schema_version,children[]})
436
435
  try {
437
436
  const outline = JSON.parse(readFileSync(join(tmpDir, OUTLINE_FILE_NAME), "utf8"));
438
437
  if (!isValidOutlineFile(outline)) {
439
- throw new HttpError(400, "VALIDATION_ERROR", "备份包内 outline.json 顶层契约不符(需 {id:root,type:root,schema_version,children[]})");
438
+ throw new HttpError(400, "VALIDATION_ERROR", "备份包内 outline.json 顶层不符(需 {id:root,type:root,schema_version,children[]})");
440
439
  }
441
440
  }
442
441
  catch (err) {
@@ -447,11 +446,11 @@ export function validateBackupPackage(zipData) {
447
446
  // data.db 校验顺序(ora-4):**文件大小 > 0 → 打开成功 → user_version**——
448
447
  // 0 字节文件 SQLite 会当新库打开(user_version=0),必须先按坏包拒绝(400 而非 409);
449
448
  // 非 SQLite 内容打开失败 → 400 坏包。
450
- // user_version 判定(E5 决议,tasks.md E5 卡规格追加句):
451
- // v === SCHEMA_VERSION → 接受(现状)
452
- // v < SCHEMA_VERSION → **有迁移路径**(MIGRATIONS 存在连续链,替换后 open 时自动前向
453
- // 迁移,数据保全完整)→ 接受;**无迁移路径** → 409(文案标注版本过旧无路径)
454
- // v > SCHEMA_VERSION → 409(E4 语义:备份来自更高版本程序)
449
+ // user_version 判定( 卡规格追加句):
450
+ // v === SCHEMA_VERSION → 接受(现状)
451
+ // v < SCHEMA_VERSION → **有迁移路径**(MIGRATIONS 存在连续链,替换后 open 时自动前向
452
+ // 迁移,数据保全完整)→ 接受;**无迁移路径** → 409(文案标注版本过旧无路径)
453
+ // v > SCHEMA_VERSION → 409(备份来自更高版本程序)
455
454
  const dbPath = join(tmpDir, DATA_DB_FILE_NAME);
456
455
  if (statSync(dbPath).size === 0) {
457
456
  throw new HttpError(400, "VALIDATION_ERROR", "备份包内 data.db 为空文件(损坏包)");
@@ -481,7 +480,7 @@ export function validateBackupPackage(zipData) {
481
480
  rmSync(tmpDir, { recursive: true, force: true });
482
481
  }
483
482
  }
484
- // ============ 自动定时器(决策 27:有变更才备份,跟随项目生命周期) ============
483
+ // ============ 自动定时器(有变更才备份,跟随项目生命周期) ============
485
484
  /** 备份频率读侧语义(与 shared 读映射一致 + B2.1 疑问裁决 2):缺失 → 缺省 10;null/0/非枚举 → 关闭(null) */
486
485
  function resolveBackupFrequency(value) {
487
486
  if (value === undefined)
@@ -508,7 +507,7 @@ function latestBackupTime(backupsDir) {
508
507
  return latest;
509
508
  }
510
509
  /**
511
- * 三文件 + data.db-wal 伴生文件是否在 since 之后有变更(决策 27「任一 mtime 晚于
510
+ * 三文件 + data.db-wal 伴生文件是否在 since 之后有变更(「任一 mtime 晚于
512
511
  * 上次备份时刻」;F2 修订:data.db 以 WAL 模式运行,普通写事务只追加 -wal 伴生文件、
513
512
  * 主文件 mtime 不变,故 data.db-wal 一并纳入判定):
514
513
  * 任一文件 mtime > since + 容差 → 有变更;**主文件缺失 → 视为有变更**(防御:不静默
@@ -539,7 +538,7 @@ function hasFileChangesSince(project, since) {
539
538
  if (err.code !== "ENOENT")
540
539
  return true;
541
540
  }
542
- // references/ 目录(决策 43):内文件(含 .trash/)任一 mtime 晚于 limit → 有变更——
541
+ // references/ 目录:内文件(含 .trash/)任一 mtime 晚于 limit → 有变更——
543
542
  // 本地新增/外部编辑 md 文档同样触发自动备份;目录缺失 = 无文件跳过;
544
543
  // 遍历竞态(读取中删除)→ 防御视为无变更(下一 tick 重检)
545
544
  try {
@@ -556,11 +555,11 @@ function hasFileChangesSince(project, since) {
556
555
  /**
557
556
  * 自动备份单次检查(定时器 tick 核心,纯同步、可单测):
558
557
  *
559
- * 1. 频率判定:关闭(null/0/非枚举)→ 直接返回 false(决策 27:null/0 = 关闭;
560
- * B2.1 疑问裁决 2:读侧非枚举值按关闭处理,不开启自动备份)
558
+ * 1. 频率判定:关闭(null/0/非枚举)→ 直接返回 falsenull/0 = 关闭;
559
+ * B2.1 疑问裁决 2:读侧非枚举值按关闭处理,不开启自动备份)
561
560
  * 2. 变更判定:`.backups/` 为空(无上次备份时刻)→ 需要备份;否则三文件 +
562
- * data.db-wal 伴生文件(F2:WAL 模式写只刷新 -wal)mtime 均早于
563
- * 「上次备份时刻 + 容差」→ 跳过(无变更不产生垃圾备份,决策 27)
561
+ * data.db-wal 伴生文件(F2:WAL 模式写只刷新 -wal)mtime 均早于
562
+ * 「上次备份时刻 + 容差」→ 跳过(无变更不产生垃圾备份)
564
563
  * 3. 有变更 → writeBackup(含保留策略清理)
565
564
  *
566
565
  * @returns 本次是否生成了备份
@@ -618,11 +617,11 @@ function scheduleNext(project) {
618
617
  /**
619
618
  * 用备份包内容替换目录三文件(restore 与 import 分流共用,B2.3 提取):
620
619
  *
621
- * - project.json:JSON 解析后按 opts 覆盖——`keepId`(决策 27:覆盖恢复以 project_id 为
622
- * 唯一 key,换 id 即断连 chat_messages 会话历史——决策 18;备份包可能来自异项目,
623
- * 防御性强制保留)与 `name`(「目录名 = 书名」不变式:import 覆盖归一为目录名、
624
- * 新书导入同步为去重名);其余字段随备份替换。序列化走 2 空格缩进 + 尾换行
625
- * (与 db 包 writeJsonAtomic 同款格式惯例)
620
+ * - project.json:JSON 解析后按 opts 覆盖——`keepId`(覆盖恢复以 project_id 为
621
+ * 唯一 key,换 id 即断连 chat_messages 会话历史——;备份包可能来自异项目,
622
+ * 防御性强制保留)与 `name`(「目录名 = 书名」不变式:import 覆盖归一为目录名、
623
+ * 新书导入同步为去重名);其余字段随备份替换。序列化走 2 空格缩进 + 尾换行
624
+ * (与 db 包 writeJsonAtomic 同款格式惯例)
626
625
  * - outline.json / data.db:原样字节原子写
627
626
  *
628
627
  * 注意:本函数只管文件层,不含 data.db 连接管理(重连见 overwriteProjectFiles)。
@@ -645,7 +644,7 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
645
644
  replaced.push(OUTLINE_FILE_NAME);
646
645
  writeFileAtomic(join(dir, DATA_DB_FILE_NAME), entries[DATA_DB_FILE_NAME]);
647
646
  replaced.push(DATA_DB_FILE_NAME);
648
- // 决策 43:references/ 目录整体覆盖(清空现有 → 写回备份条目;含 .trash/)——
647
+ // references/ 目录整体覆盖(清空现有 → 写回备份条目;含 .trash/)——
649
648
  // 恢复/导入是「整体还原」语义,目录以备份内容为准,本地残留不混入
650
649
  const refDir = join(dir, REFERENCE_DIR_NAME);
651
650
  if (existsSync(refDir))
@@ -669,15 +668,15 @@ export function writeProjectFilesFromBackup(dir, entries, opts = {}) {
669
668
  }
670
669
  }
671
670
  /**
672
- * 决策 41 自动迁移:project.json 存在非空 `prompt` 且项目目录无 AGENTS.md
673
- * 将 prompt 内容**原样**写入 AGENTS.md(原子写,决策 11 同款),一次性——
674
- * 迁移后 AGENTS.md 存在,条件不再满足,prompt 不再使用(字段可保留为遗留数据,宽松读取)。
671
+ * 自动迁移:project.json 存在非空 `prompt` 且项目目录无 →
672
+ * 将 prompt 内容**原样**写入 (原子写, 同款),一次性——
673
+ * 迁移后 存在,条件不再满足,prompt 不再使用(字段可保留为遗留数据,宽松读取)。
675
674
  *
676
675
  * 触发点:打开项目时(detectProject 启动即打开 + open 路由)+ restore/import 覆盖路径
677
- * (overwriteProjectFiles 同步 config 后——向无 AGENTS.md 的项目恢复含遗留 prompt 的旧备份时,
678
- * 内存 config 有 prompt 但 AGENTS.md 未创建,chat 读 AGENTS.md 为 null,恢复的 prompt 在下次
679
- * open 前不注入)。**幂等**(prompt 非空且无 AGENTS.md 才迁移),**失败不阻塞**(记录日志,
680
- * 下次 open 重试——schema.md「迁移在 open 流程内完成,失败不阻塞打开」)。
676
+ * (overwriteProjectFiles 同步 config 后——向无 的项目恢复含遗留 prompt 的旧备份时,
677
+ * 内存 config 有 prompt 但 未创建,chat 读 为 null,恢复的 prompt 在下次
678
+ * open 前不注入)。**幂等**(prompt 非空且无 才迁移),**失败不阻塞**(记录日志,
679
+ * 下次 open 重试——「迁移在 open 流程内完成,失败不阻塞打开」)。
681
680
  *
682
681
  * 注:本函数置于 backup.ts 而非 middleware/project.ts——backup 模块不依赖 middleware
683
682
  * (避免运行时循环依赖,见文件头注释),middleware 侧经 ../backup.js 导入复用。
@@ -689,7 +688,7 @@ export function migratePromptToAgents(project) {
689
688
  if (typeof prompt !== "string" || prompt.trim() === "")
690
689
  return; // 无 prompt 无需迁移
691
690
  if (readAgentsFile(project.root) !== null)
692
- return; // 已有 AGENTS.md 不覆盖(一次性)
691
+ return; // 已有 不覆盖(一次性)
693
692
  try {
694
693
  writeAgentsFile(project.root, prompt);
695
694
  }
@@ -702,20 +701,20 @@ export function migratePromptToAgents(project) {
702
701
  * 覆盖管道(restore 与 import 覆盖当前打开的书共用,B2.3 从 restoreBackup 提取):
703
702
  *
704
703
  * 1. 释放当前 data.db 连接(替换 data.db 前必须;顺带清理陈旧 WAL/SHM 残留——
705
- * 替换后新库不得复用旧 WAL)
704
+ * 替换后新库不得复用旧 WAL)
706
705
  * 2. writeProjectFilesFromBackup 原子替换三文件(保留当前项目 id;opts.name 归一)
707
- * 3. 重连 + 版本对齐(v < 当前且有迁移路径 → 前向迁移,E5;v === 当前 → 原样。
708
- * 校验阶段已拒绝 v > 当前与无路径旧版,此处不会触发重建/拒绝)
706
+ * 3. 重连 + 版本对齐(v < 当前且有迁移路径 → 前向迁移,v === 当前 → 原样。
707
+ * 校验阶段已拒绝 v > 当前与无路径旧版,此处不会触发重建/拒绝)
709
708
  * 4. 同步内存 config(刚原子写入,readProjectFile 必非 null;损坏抛错由 catch 恢复连接)
710
- * 5. 决策 41:migratePromptToAgents(恢复的 project.json 含遗留 prompt 且无 AGENTS.md → 迁移)
709
+ * 5. migratePromptToAgents(恢复的 project.json 含遗留 prompt 且无 → 迁移)
711
710
  * 6. 重启定时器(备份包内频率可能不同:5 → 60 等)
712
711
  *
713
- * 调用方职责:覆盖前自动快照 + 备份包校验(validateBackupPackage)。
714
- * 失败时尝试恢复连接(原库可能未被替换或已替换为校验过的有效库),保证后续请求
715
- * 不因悬挂连接报 500「connection not open」;**重连失败时连接悬挂**——由路由层
716
- * (restore/import)在 catch 后检查 project.db.open 清空 currentProject 单例
717
- * (backup 模块不依赖 middleware,避免运行时循环依赖)。
718
- */
712
+ * 调用方职责:覆盖前自动快照 + 备份包校验(validateBackupPackage)。
713
+ * 失败时尝试恢复连接(原库可能未被替换或已替换为校验过的有效库),保证后续请求
714
+ * 不因悬挂连接报 500「connection not open」;**重连失败时连接悬挂**——由路由层
715
+ * (restore/import)在 catch 后检查 project.db.open 清空 currentProject 单例
716
+ * (backup 模块不依赖 middleware,避免运行时循环依赖)。
717
+ */
719
718
  export function overwriteProjectFiles(project, entries, opts = {}) {
720
719
  const dbPath = join(project.root, DATA_DB_FILE_NAME);
721
720
  closeDatabase(project.db); // 释放当前连接(替换 data.db 前必须;替换失败恢复见 catch)
@@ -746,10 +745,10 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
746
745
  throw new HttpError(500, "INTERNAL_ERROR", "覆盖后 project.json 读取失败");
747
746
  }
748
747
  project.config = restoredConfig;
749
- // 决策 41(oracle 评审修复):restore/import 覆盖路径同步触发 AGENTS.md 迁移——
750
- // 向无 AGENTS.md 的项目恢复含遗留 prompt 的旧备份时,内存 config 有 prompt 但 AGENTS.md
751
- // 未创建,chat 读 AGENTS.md 为 null,恢复的 prompt 在下次 open 前不注入。
752
- // migratePromptToAgents 幂等(prompt 非空且无 AGENTS.md 才迁移),失败不阻塞覆盖。
748
+ // (oracle 评审修复):restore/import 覆盖路径同步触发 迁移——
749
+ // 向无 的项目恢复含遗留 prompt 的旧备份时,内存 config 有 prompt 但
750
+ // 未创建,chat 读 为 null,恢复的 prompt 在下次 open 前不注入。
751
+ // migratePromptToAgents 幂等(prompt 非空且无 才迁移),失败不阻塞覆盖。
753
752
  migratePromptToAgents(project);
754
753
  }
755
754
  catch (err) {
@@ -769,7 +768,7 @@ export function overwriteProjectFiles(project, entries, opts = {}) {
769
768
  startAutoBackup(project); // 重启定时器(覆盖包内频率可能不同)
770
769
  }
771
770
  /**
772
- * 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,决策 27「覆盖前自动快照」):
771
+ * 为**未打开的书**生成覆盖前快照(import 覆盖书架其他书场景,「覆盖前自动快照」):
773
772
  * 临时打开 data.db 连接走备份管道(wal_checkpoint 保证快照完整性),完成后关闭连接。
774
773
  * 已打开的书走 writeBackup(project)(复用现有连接,见 import 路由分流)。
775
774
  */
@@ -787,7 +786,7 @@ export function snapshotBookDir(dir) {
787
786
  }
788
787
  }
789
788
  /**
790
- * 原子写任意字节(决策 11 同款流程:写同目录临时文件 → fsync → rename 覆盖;
789
+ * 原子写任意字节( 同款流程:写同目录临时文件 → fsync → rename 覆盖;
791
790
  * 供 restore/import 替换三文件用——JSON 版见 db 包 writeJsonAtomic,此处为通用 bytes 版)。
792
791
  * 失败保留临时文件供排查,原文件未被触碰。
793
792
  */
@@ -823,27 +822,27 @@ function writeFileAtomic(filePath, data) {
823
822
  }
824
823
  }
825
824
  /**
826
- * 从备份恢复当前项目(POST /project/backup/restore,决策 27):
825
+ * 从备份恢复当前项目(POST /project/backup/restore):
827
826
  *
828
- * 1. fileName 白名单校验:仅允许 `.backups/` 下时间戳格式(决策 28/29 兼容四类:
829
- * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip` 毫秒级(含 kind 段)/ `<YYYYMMDD-HHmmssSSS>-<名称>.zip`
830
- * 旧带名称 / 旧秒级 `<YYYYMMDD-HHmmss>.zip`——shared parseBackupFileName,^$ 锚定 + 名称部分
831
- * 拒绝 /\\,天然防路径分隔符与 `..` 穿越);格式合法但文件不存在 → 404
827
+ * 1. fileName 白名单校验:仅允许 `.backups/` 下时间戳格式( 兼容四类:
828
+ * `<YYYYMMDD-HHmmssSSS>[-<kind>][-<名称>].zip` 毫秒级(含 kind 段)/ `<YYYYMMDD-HHmmssSSS>-<名称>.zip`
829
+ * 旧带名称 / 旧秒级 `<YYYYMMDD-HHmmss>.zip`——shared parseBackupFileName,^$ 锚定 + 名称部分
830
+ * 拒绝 /\\,天然防路径分隔符与 `..` 穿越);格式合法但文件不存在 → 404
832
831
  * 2. **覆盖前自动快照**:当前三文件打包为快照存入 .backups/(复用备份管道,
833
- * 就是普通备份文件,自然参与保留策略——后悔药,决策 27)
834
- * 3. 备份包校验(validateBackupPackage:zip/白名单/契约/user_version 三态,
835
- * E4/E5 语义,拒绝时数据零触碰——校验通过前不触碰任何数据文件)
832
+ * 就是普通备份文件,自然参与保留策略——后悔药)
833
+ * 3. 备份包校验(validateBackupPackage:zip/白名单//user_version 三态,
834
+ * 拒绝时数据零触碰——校验通过前不触碰任何数据文件)
836
835
  * 4. **原子替换**:closeDatabase(释放当前连接)→ 三文件逐文件原子写
837
- * (临时文件 + rename;data.db 顺带清理 -wal/-shm 残留)→ 重新 openDatabase
838
- * + ensureSchemaCompatible(校验阶段已保证 v ≤ 当前且有迁移路径或匹配——
839
- * v < 有路径时此处前向迁移,E5)→ project.config 同步新 project.json
840
- * 5. 项目 id 保留(当前项目引用不变,决策 27);重启定时器(频率可能随备份变化)
836
+ * (临时文件 + rename;data.db 顺带清理 -wal/-shm 残留)→ 重新 openDatabase
837
+ * + ensureSchemaCompatible(校验阶段已保证 v ≤ 当前且有迁移路径或匹配——
838
+ * v < 有路径时此处前向迁移)→ project.config 同步新 project.json
839
+ * 5. 项目 id 保留(当前项目引用不变);重启定时器(频率可能随备份变化)
841
840
  *
842
- * @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
843
- */
841
+ * @throws HttpError 400(文件名非法/坏包)、404(备份不存在)、409 SCHEMA_VERSION_MISMATCH
842
+ */
844
843
  export function restoreBackup(project, fileName) {
845
844
  // 1. 白名单校验(assertBackupFileNameFormat:parseBackupFileName 全格式校验,防路径穿越;
846
- // 决策 28/29 兼容毫秒级/带 kind 段/旧带名称/旧秒级文件名)
845
+ // 兼容毫秒级/带 kind 段/旧带名称/旧秒级文件名)
847
846
  assertBackupFileNameFormat(fileName);
848
847
  const backupPath = join(project.root, BACKUPS_DIR_NAME, fileName);
849
848
  if (!existsSync(backupPath)) {
@@ -855,19 +854,19 @@ export function restoreBackup(project, fileName) {
855
854
  const zip = readFileSync(backupPath);
856
855
  const { entries, projectId: zipProjectId } = validateBackupPackage(zip);
857
856
  // 4. 覆盖管道(B2.3 提取,import 覆盖复用):原子替换三文件(保留当前 id)+
858
- // 重连 data.db + 同步 config + 重启定时器。
859
- // name 归一为当前目录名(审核裁决:与 import 覆盖一致,维持「目录名 = 书名」
860
- // 不变式——id 是身份、name 是展示名;改名需求走 /project/rename)
857
+ // 重连 data.db + 同步 config + 重启定时器。
858
+ // name 归一为当前目录名(审核裁决:与 import 覆盖一致,维持「目录名 = 书名」
859
+ // 不变式——id 是身份、name 是展示名;改名需求走 /project/rename)
861
860
  overwriteProjectFiles(project, entries, { name: basename(project.root), snapshotFileName: snapshot.fileName });
862
- // 5. 会话归属迁移(B2.2 审核 P1-1,决策 18/27):备份包内 project_id ≠ 当前项目 id
863
- // (跨项目恢复,如手工放入 .backups/ 的异项目备份)→ chat_messages 旧 id 行迁移为
864
- // 当前 id——「保留 id 保会话」的理由在跨项目场景同样成立:不迁移则恢复后聊天面板
865
- // 静默为空、旧会话行成孤儿数据。同项目恢复(id 相等)跳过,不执行多余迁移。
866
- // (import 覆盖无需迁移:id 匹配才走覆盖分支,zip id = 书架 id)
861
+ // 5. 会话归属迁移(B2.2 审核 P1-1):备份包内 project_id ≠ 当前项目 id
862
+ // (跨项目恢复,如手工放入 .backups/ 的异项目备份)→ chat_messages 旧 id 行迁移为
863
+ // 当前 id——「保留 id 保会话」的理由在跨项目场景同样成立:不迁移则恢复后聊天面板
864
+ // 静默为空、旧会话行成孤儿数据。同项目恢复(id 相等)跳过,不执行多余迁移。
865
+ // (import 覆盖无需迁移:id 匹配才走覆盖分支,zip id = 书架 id)
867
866
  if (zipProjectId !== project.config.id) {
868
867
  migrateChatMessagesProject(project.db, zipProjectId, project.config.id);
869
868
  }
870
- // 契约(endpoints.md):snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
869
+ // ():snapshot 仅含 fileName/createdAt(size 属内部信息不暴露)
871
870
  return { snapshot: { fileName: snapshot.fileName, createdAt: snapshot.createdAt } };
872
871
  }
873
872
  //# sourceMappingURL=backup.js.map