Back to Cherry Studio

RFC: 文件管理

v2-refactor-temp/docs/file-manager/rfc-file-manager.md

2.0.081.4 KB
Original Source

RFC: 文件管理

定位:实现设计文档。包含数据 Schema、API 契约、核心流程伪代码、迁移策略与分阶段计划。

架构决策(系统边界、组件职责、数据流)以 docs/references/file/architecture.mddocs/references/file/file-manager-architecture.md 为准。本文档中与架构文档冲突的内容,以架构文档为 Source of Truth。

相关文档:

  • file-arch-problems.md — 旧架构问题清单
  • file-arch-problems-response.md — 各问题在新架构下的回应与设计决策
  • migration-plan.md — 字段级退役 + 消费域切换 + 跨模块协调的详细执行计划
  • utils-file-migration.mdsrc/main/utils/file/ 整合方案(v1 legacyFile.ts / fileOperations.ts → v2 @main/utils/file/{fs,metadata,path,search,shell} 的函数级分派与 phase 归属,作为 §9.3 / §9.4 编码 PR 的待办清单)

一、背景

现有文件管理架构的结构性问题详见 file-arch-problems.md,新架构下各问题的解决方案与决策依据见 file-arch-problems-response.md

本 RFC 聚焦实现层面:数据 Schema、API 契约、核心流程、迁移步骤、分阶段计划。核心取向:

  • 扁平 FileEntry + 多态 FileRef——持久化层不引入目录树、不引入 mount 概念
  • origin: internal / external 二态——Cherry 拥有 vs 用户拥有
  • 无内容去重——每个显式上传都是独立 FileEntry
  • Notes / 其他 FS-first 业务解耦——不强制镜像到 file_entry
  • 类型分层:引用 vs 数据形状——FileHandle 是跨边界的多态引用层;FileEntry(managed)与 FileInfo(unmanaged)是两种"数据形状"。旧 FileMetadata 同时承担"DB 行"与"通用文件描述符"两个角色,v2 把这两个角色显式拆分:持久化角色 → FileEntry,描述符角色 → FileInfo。详见 architecture.md §2
  • AI SDK upload 延后——待 Vercel AI SDK Files API 稳定后以独立 PR 引入

二、范畴说明

本 RFC 覆盖

  • file_entry / file_ref 两张表的 Drizzle Schema
  • DataApi(只读)+ File IPC(读写)契约
  • FileManager 核心流程伪代码(createInternalEntry / ensureExternalEntry / read / write / trash / restore / permanentDelete / rename / copy)
  • OrphanRefScanner 注册式 checker 设计
  • Dexie → SQLite 的 FileMigrator 流程
  • 分阶段实施计划(Phase 1a / 1b.1-4 / 2 / X)

不在范畴

  • 具体 UI 改动(文件页、对话附件 picker 等属业务 PR)
  • Notes 模块设计(独立 RFC)
  • AI SDK Files API 集成(延后独立 PR,file-manager-architecture.md §9 保留设计意图)
  • Painting 业务重构(仅依赖 FileMigrator 提供的 fileId,随 Painting 重构独立推进)
  • 字段级退役与消费域切换的详细步骤(见 migration-plan.md

三、设计目标

  • 统一主进程入口:消除跨进程一致性风险(问题 1/2/3/11)
  • 放弃内容去重:每个上传独立 entry,用户视角不混淆(问题 4)
  • 显式引用关系file_ref 表替代不透明的 count,可反查业务来源(问题 5/7)
  • 元数据生产收口:ext/type 推断统一在 main 侧(问题 13)
  • 持久化层解耦 Notes:不强制镜像笔记文件到 file_entry(问题 9/10)
  • 为扩展预留空间:AI SDK upload、DirectoryTreeBuilder primitive 等(问题 12)

四、数据模型(Drizzle Schema)

4.1 设计决策

决策结论理由
FileEntry 结构扁平(无 parentId、无 mount)持久化层不做目录树;Notes 自治(问题 6/10)
主键策略UUID v7(uuidPrimaryKeyOrdered);旧数据保留 v4新 entry 时间有序;旧 v4 ID 跨表引用零翻译(migration-plan §2.9)
origin 枚举'internal' | 'external'Cherry 拥有 vs 用户拥有;语义清晰
External path 唯一性Global unique index on externalPath(internal 行为 null,SQLite UNIQUE 视多个 NULL 互不冲突,天然只约束 external 行)同 path 全局最多一条;ensureExternalEntry 纯 upsert by path,无 "restore trashed" 分支
size 字段必填(INTEGER NOT NULL)查询/排序需要;external 为最后观测的快照
trash 语义deletedAt 时间戳;仅对 internal 有效,external 由 fe_external_no_delete CHECK 禁止 trashedinternal 保留软删可逆窗口;external 生命周期单向(Active → Deleted),重建成本为零所以不需要撤销
external 删除语义permanentDelete 只删 DB 行;物理文件不动(path-level ops.remove 独立提供)Cherry 不在 entry-level 自动 unlink 用户拥有的文件;用户有需要时走独立的 unmanaged 删除通道
sourceType / role应用层 Zod 验证 + 编译期 checker 注册新增 sourceType 无需 DB migration
file_ref 防重UNIQUE(fileEntryId, sourceType, sourceId, role)一个业务对象不会以同一角色重复引用同一文件
DataApi 职责只读 + 允许幂等副作用(SQL 聚合、fs.stat所有 mutation 走 File IPC
Upload 派生数据延后引入 file_uploadVercel AI SDK Files API 未稳定

4.2 fileEntryTable

typescript
import { sql } from "drizzle-orm";
import {
  check,
  index,
  integer,
  sqliteTable,
  text,
  uniqueIndex,
} from "drizzle-orm/sqlite-core";

import {
  createUpdateTimestamps,
  uuidPrimaryKeyOrdered,
} from "./_columnHelpers";

export const fileEntryTable = sqliteTable(
  "file_entry",
  {
    id: uuidPrimaryKeyOrdered(),

    /** 'internal' | 'external' */
    origin: text().notNull(),

    /** 用户可见名称,不含扩展名。internal 为 SoT;external 为 basename 快照 */
    name: text().notNull(),
    /** 扩展名,不含前导点('pdf' / 'md');无扩展名为 null */
    ext: text(),
    /** 字节数。internal 为 SoT;external 为最后观测快照 */
    size: integer().notNull(),

    /** 用户侧绝对路径。仅 origin='external' 非空 */
    externalPath: text(),

    /**
     * 软删时间戳(ms epoch);null 表示未 trash。**仅 internal 可用**;
     * external 恒为 null(由 `fe_external_no_delete` CHECK 强制)。
     */
    deletedAt: integer(),

    ...createUpdateTimestamps,
  },
  (t) => [
    index("fe_deleted_at_idx").on(t.deletedAt),
    index("fe_created_at_idx").on(t.createdAt),
    // 同 externalPath 全局最多一条。internal 行为 null,SQLite 视多个 NULL
    // 互不冲突,因此天然只约束 external 行。兼任查询索引。
    uniqueIndex("fe_external_path_unique_idx").on(t.externalPath),
    check("fe_origin_check", sql`${t.origin} IN ('internal', 'external')`),
    check(
      "fe_origin_consistency",
      sql`(${t.origin} = 'internal' AND ${t.externalPath} IS NULL) OR (${t.origin} = 'external' AND ${t.externalPath} IS NOT NULL)`,
    ),
    // External 不可 trashed:trash/restore 仅对 internal,external 走 permanentDelete
    check(
      "fe_external_no_delete",
      sql`${t.origin} != 'external' OR ${t.deletedAt} IS NULL`,
    ),
  ],
);

字段权威性矩阵

字段origin='internal'origin='external'
nameSoT(用户可改名)上次 observe 的 basename 快照
extSoT上次 observe 的扩展名
sizeSoT上次 observe 的字节数
externalPathNULL绝对路径(external 身份)

4.3 fileRefTable

typescript
export const fileRefTable = sqliteTable(
  "file_ref",
  {
    id: uuidPrimaryKey(),

    fileEntryId: text()
      .notNull()
      .references(() => fileEntryTable.id, { onDelete: "cascade" }),

    /** 业务来源类型('chat_message' / 'knowledge_item' / 'painting' / ...) */
    sourceType: text().notNull(),
    /** 业务对象 ID(polymorphic, no FK) */
    sourceId: text().notNull(),
    /** 引用角色('attachment' / 'source' / 'asset' / ...) */
    role: text().notNull(),

    ...createUpdateTimestamps,
  },
  (t) => [
    index("file_ref_entry_id_idx").on(t.fileEntryId),
    index("file_ref_source_idx").on(t.sourceType, t.sourceId),
    uniqueIndex("file_ref_unique_idx").on(
      t.fileEntryId,
      t.sourceType,
      t.sourceId,
      t.role,
    ),
  ],
);

设计要点:

  • fileEntryId CASCADE:删除 entry 自动清理其所有 ref
  • sourceId 无 FK:polymorphic 多态;依赖应用层清理 + 孤儿扫描兜底(§六)
  • UNIQUE:防重复引用(同一文件不会被同一业务对象以同一角色引用两次)

4.4 Upload 表(延后)

Vercel AI SDK SharedV4ProviderReference 集成所需的 file_upload 表在 SDK Files API 稳定后独立 PR 引入。设计意图见 file-manager-architecture.md §9,不在 Phase 1 交付物内。

4.5 DTO 类型定义

位于 src/shared/data/types/file/(managed 数据形状与引用图):

文件内容
essential.tsTimestampSchemaSafeNameSchema 等基础 schema
fileEntry.tsFileEntrySchemaz.discriminatedUnion('origin') + .brand<'FileEntry'>())、FileEntryIdSchemaDanglingStateSchema
ref/FileRefSchemaz.discriminatedUnion('sourceType'),不 brand)、createRefSchema 工厂
index.tsBarrel re-export

位于 src/shared/file/types/(跨边界引用层与 path-indexed 数据形状):

文件内容
common.tsFilePath / FileType / PhysicalFileMetadata 等基础类型
handle.tsFileHandle tagged union、createFileEntryHandle / createFilePathHandle 工厂
info.tsFileInfo(path-indexed 数据形状,见 §4.5.3)
ipc.tsFile IPC 方法签名
index.tsBarrel re-export

4.5.1 Brand type 强化(解决问题 13)

动机FileEntry派生字段——name/ext 由 basename 切分、typeext 派生、refCount/dangling/path/url 是 DataApi 按需聚合。这些派生只有在 sanctioned 路径(main 侧)才能正确产生。旧 FileMetadata 是普通 interface,允许对象字面量满足——renderer / 业务代码自拼 entry 会破坏派生统一性。

解法只给 FileEntry 一个类型加 brand——让对象字面量无法满足类型,只有经过 FileEntrySchema.parse() 的值才是 FileEntry

typescript
// src/shared/data/types/file/fileEntry.ts
export const FileEntryIdSchema = z.uuid(); // 普通字符串,不 brand

export const FileEntrySchema = z
  .discriminatedUnion("origin", [InternalEntrySchema, ExternalEntrySchema])
  .brand<"FileEntry">();

export type FileEntryId = z.infer<typeof FileEntryIdSchema>;
export type FileEntry = z.infer<typeof FileEntrySchema>;

效果

  • const e: FileEntry = { id, origin, name, ... } → 编译错误(缺 brand,拒绝绕过派生的鸭子对象)
  • const e = FileEntrySchema.parse(raw) → OK,Zod 自动施加 brand
  • const e2: FileEntry = { ...e, name: 'x' } → 编译错误(spread 丢 brand)——修改被迫走 rename IPC 等 sanctioned mutator

范围严控:仅 FileEntry 一个类型加 brand。其他类型(FileEntryId / FileRef / FileRefId)保持普通 z.infer 类型——它们没有派生字段(ID 是纯字符串,FileRef 是纯行),加 brand 只会给测试和 main 内部代码增加无谓的 parse 样板,不换保护。

生产点仅三条(每条都显式 parse):

生产者位置
createInternalEntry / ensureExternalEntry / batchCreateInternalEntries / batchEnsureExternalEntries IPCFileManager 返回前 parse
DataApi handler(row → DTO)src/main/data/api/handlers/files.ts 响应前 parse;固定 shape,无 opt-in 派生
File IPC enrichment(dangling / path / url)FileManager 专用方法内部计算(见 §七)
FileMigrator insertFileMigrator 转换后 parse

Test 逃生舱tests/__mocks__/factories.ts 提供 makeFileEntry(overrides),内部仍走 FileEntrySchema.parse——mock 数据也经过 schema 校验,不留 unbranded 后门。

运行期防线:brand 是编译期约束,运行期 as FileEntry 仍可绕;真正的运行时防线是 IPC 边界与 DataApi 响应边界的显式 parse,保证即便 TS 被绕过,数据形状依然合法。

4.5.2 推断类型

typescript
type FileEntry = z.infer<typeof FileEntrySchema>; // branded, discriminated on origin
type InternalFileEntry = z.infer<typeof InternalEntrySchema>;
type ExternalFileEntry = z.infer<typeof ExternalEntrySchema>;
type FileRef = z.infer<typeof FileRefSchema>; // 不 branded(纯行,无派生)
type FileEntryId = z.infer<typeof FileEntryIdSchema>; // 不 branded;z.uuid() 接受 v4 / v7
type DanglingState = z.infer<typeof DanglingStateSchema>; // 'present' | 'missing' | 'unknown'

API DTO:DataApi 的 /files/entries 端点响应 shape 固定为 FileEntry(branded)——无 opt-in 派生字段。引用计数由专用端点 /files/entries/ref-counts 返回 FileEntryRefCount[](纯 SQL);dangling / path / url 等 FS/resolver 派生一律走 File IPC(见 §七)。旧设计的 FileEntryView 类型(带 opt-in refCount? / dangling? / path? / url?)已作废。

FileEntryIdSchema 使用 z.uuid() 而非 z.uuidv7(),以接受旧数据的 v4 ID(见 migration-plan §2.9)。

4.5.3 FileInfo(unmanaged 数据形状)

位于 src/shared/file/types/info.ts

typescript
interface FileInfo {
  readonly path: FilePath       // unmanaged 身份字段;绝对路径
  readonly name: string         // basename 去扩展名(对齐 FileEntry.name)
  readonly ext: string | null   // 扩展名不含前导点(对齐 FileEntry.ext)
  readonly size: number         // fs.stat 实时
  readonly mime: string         // 由 ext 派生,未知时 'application/octet-stream'
  readonly type: FileType       // 由 ext 派生
  readonly createdAt: number    // fs 出生时间(ms epoch;不可靠时回退 mtime)
  readonly modifiedAt: number   // fs mtime(ms epoch)
}

定位FileInfopath 引用下的文件数据形状——与 FilePathHandle 在引用层对应。它不承载身份(无 id、无 origin、无 deletedAt),只承载"磁盘上此刻的一份描述"。

FileEntry 的关系

对比项FileEntryFileInfo
身份字段idpath
活性快照(与物理状态解耦)实时(每次读都可能不同)
生命周期持久化;internal 有 trash/restore瞬态——随调用产生即走
生产入口createInternalEntry / ensureExternalEntryops.stat / toFileInfo(entry)
brand有(强制走 sanctioned 生产路径)无(可自由构造)
同名字段语义size 为注册时快照;external 可能 driftsize 为 fs.stat 实时读取

投影方向单一FileEntry → FileInfo 通过 toFileInfo(entry) 异步投影(需要 fs.stat + 根据 origin 做 path 解析)。反向不是类型转换——从 FileInfo 得到 FileEntry 必须走 createInternalEntry / ensureExternalEntry,因为它是状态变更(注册),不是形状变换。FileEntrySchema 的 brand 会挡住任何对象字面量式的伪造。

签名选型:绝大多数公共 / IPC 方法应接 FileHandle 而非 FileInfo —— 让同一个 API 同时服务 managed 与 unmanaged。FileInfo 主要出现在:

  • 返回类型ops.stat(path) / export 产物 / backup 归档产物
  • 叶子消费者的参数:OCR / TokenService / 哈希计算等只读 path + 物理属性的纯处理函数(调用方若持有 FileEntry 需先 toFileInfo 投影)

完整选型矩阵见 architecture.md §2.4


五、核心流程

FS 操作由 ops/* 纯函数执行(唯一 FS owner)。DB 操作由 FileEntryService / FileRefService 执行(纯 DB repository)。FileManager 做协调与 IPC 分派。

5.1 Entry 创建:createInternalEntry + ensureExternalEntry

公开 API 按语义严格拆分(见 file-manager-architecture.md §1.6):

  • createInternalEntry(params) —— 总是 insert,每次产生新 UUID
  • ensureExternalEntry(params) —— 按 externalPath 纯 upsert:reuse / insert 两路之一,幂等(external 恒非 trashed,无 restore 分支)。external 行不存 size(CHECK 强制为 NULL),live 值由 getMetadata 提供
typescript
// CreateInternalEntryParams 是 source-discriminated union:
//   | { source: 'path',   path: FilePath }
//   | { source: 'url',    url: URLString }
//   | { source: 'base64', data: Base64String; name?: string }
//   | { source: 'bytes',  data: Uint8Array;   name: string; ext: string | null }
// 类型门把"能从 content 派生的字段"在可派生分支上直接 hide,避免调用方冗余/矛盾输入。
// 完整契约与决策说明见 `src/shared/file/types/ipc.ts` + `file-arch-problems-response.md`(A-7 延伸)。

// createInternalEntry: 复制 / 移动内容到 {userData}/files/{id}.{ext}
async function createInternalEntry(
  params: CreateInternalEntryParams,
): Promise<FileEntry> {
  const id = uuidv7();
  const { name, ext, bytes } = await resolveInternalSource(params);
  const dest = resolvePhysicalPath({ id, ext, origin: "internal" });

  // 1. 原子写物理文件
  await ops.atomicWriteFile(dest, bytes);
  const { size } = await ops.stat(dest);

  // 2. 写入 DB
  return fileEntryService.create({ id, origin: "internal", name, ext, size });
}

// resolveInternalSource: 按 source 分支派生 name/ext/bytes
async function resolveInternalSource(p: CreateInternalEntryParams) {
  switch (p.source) {
    case "path": {
      const bytes = await ops.createReadStream(p.path);
      return { ...splitName(path.basename(p.path)), bytes };
    }
    case "url": {
      const res = await fetch(p.url);
      return {
        ...deriveFromUrl(p.url, res.headers), // 末段 / Content-Disposition / Content-Type
        bytes: new Uint8Array(await res.arrayBuffer()),
      };
    }
    case "base64": {
      const { mime, bytes } = decodeDataUrl(p.data);
      return {
        name: p.name ?? synthesizeName(mime),
        ext: mimeToExt(mime),
        bytes,
      };
    }
    case "bytes":
      return { name: p.name, ext: p.ext, bytes: p.data };
  }
}

// ensureExternalEntry: 按 externalPath 纯 upsert
async function ensureExternalEntry(
  params: EnsureExternalEntryParams,
): Promise<FileEntry> {
  // Phase 1b.1 同步廉价 canonicalize: path.resolve + NFC + trailing-sep strip.
  // 不含 fs.realpath(case-insensitive FS 去重由 Phase 2 视用户反馈补)。
  // 是 upsert/查询的唯一 key 来源。
  const canonicalPath = canonicalizeAbsolutePath(params.externalPath);
  // External 恒非 trashed(fe_external_no_delete CHECK),所以不需要 includeTrashed。
  const existing = await fileEntryService.findByExternalPath(canonicalPath);
  if (existing) return existing; // name/ext 来自 externalPath,不会漂移;size 不存

  await ops.stat(canonicalPath); // 纯探测:路径必须存在;副作用是更新 DanglingCache
  const { name, ext } = splitName(path.basename(canonicalPath));

  return fileEntryService.create({
    origin: "external",
    name,
    ext,
    size: null, // external 不存 size(fe_size_internal_only CHECK),live 值走 getMetadata
    externalPath: canonicalPath,
  });
}

原子性

  • createInternalEntry:物理写 + DB 写两步。物理写失败 → 无 DB 行;DB 写失败 → 启动期 orphan sweep 清理残留 UUID 文件
  • ensureExternalEntry:仅 DB 写 + 一次 stat 验证;stat 失败直接抛错

5.2 read / write / writeIfUnchanged

所有接受 FileHandlemanaged | unmanaged)。

  • read:managed 解析 entryId → path 后调 ops.read;unmanaged 直接读 path
  • write:原子写(ops.atomicWriteFile),更新版本缓存;external 覆盖用户文件(显式操作语义)
  • writeIfUnchanged:乐观并发(ops.atomicWriteIfUnchanged),版本不匹配抛 StaleVersionError

详细语义见 file-manager-architecture.md §4-§6

5.3 trash / restore(软删除,仅 internal)

纯 DB 操作,不碰 FS。仅对 internal 有效——external 由 fe_external_no_delete CHECK 禁止 trashed,调用入口先校验 origin,传入 external id 直接抛错;schema 层兜底:

typescript
async function trash(id: FileEntryId): Promise<void> {
  const entry = await fileEntryService.findById(id);
  if (entry.origin === "external") {
    throw new Error(
      `Cannot trash external entry ${id}; external entries have no trashed state. Use permanentDelete.`,
    );
  }
  await fileEntryService.update(id, { deletedAt: Date.now() });
}

async function restore(id: FileEntryId): Promise<FileEntry> {
  const entry = await fileEntryService.findById(id);
  if (entry.origin === "external") {
    throw new Error(
      `Cannot restore external entry ${id}; external entries are never trashed.`,
    );
  }
  return fileEntryService.update(id, { deletedAt: null });
}

5.4 permanentDelete

物理 FS 行为按 origin 分叉:internal 真删,external 仅删 DB 行(物理文件不动;用户若想物理删除请走 unmanaged path 分支)。

typescript
async function permanentDelete(handle: FileHandle): Promise<void> {
  if (handle.kind === "unmanaged") {
    // Path-level 删除(显式、与任何 entry 解绑)
    await ops.remove(handle.path);
    return;
  }
  const entry = await fileEntryService.getById(handle.entryId);

  if (entry.origin === "internal") {
    // Cherry 拥有物理文件:unlink FS + 删 DB
    await ops.remove(resolvePhysicalPath(entry)).catch(ignoreEnoent);
  }
  // external: entry-level 删除仅动 DB 行;不触碰用户的物理文件。
  // 需要物理删的调用方应独立走 unmanaged 分支(上面)。

  await fileEntryService.delete(entry.id); // CASCADE 清 file_ref
}

5.5 rename

  • Entry handle, internal origin:纯 DB 更新 name(物理文件名是 UUID 不变)
  • Entry handle, external originops.rename(oldExternalPath, newPath) + DB 更新 externalPath / name / ext
  • Path handleops.rename(oldPath, newPath),等价于 fs.rename

5.6 copy

产出新 internal entry:

typescript
async function copy(params: {
  source: FileHandle;
  newName?: string;
}): Promise<FileEntry> {
  const sourcePath = resolveFileHandle(params.source); // → absolute FilePath
  // source: 'path' 分支 — createInternalEntry 内部会走 basename/extname 派生 name/ext。
  // newName 另走一条:若提供则在派生后 override(copy 独占的 UX 需求,不污染 core API)。
  const entry = await createInternalEntry({ source: "path", path: sourcePath });
  return params.newName ? rename(entry.id, params.newName) : entry;
}

5.7 崩溃恢复(启动期 orphan sweep)

FileManager.onInit 后台 fire-and-forget(不阻塞 ready):

  1. 扫描 {userData}/files/ 下 UUID 文件名:查 DB 找不到对应 entry → unlink
  2. 扫描 *.tmp-<uuidv7> 原子写残留 → unlink

DanglingCache 反向索引初始化为同步 DB 查询(external entries 通常 < 10k);watcher 事件与冷路径 stat 在运行期增量更新。

5.8 元数据生产统一入口(问题 13)

  • createInternalEntry / ensureExternalEntry 是 entry 创建的唯一路径——renderer 不再自己拼接 FileMetadata
  • name / ext 切分:main 侧统一在这两个方法内处理(见 migration-plan §2.7)
  • type 派生:不持久化,查询时由 ops/metadata.getFileType(ext) 计算;getMetadata 可 buffer 升级 OTHER → 具体类型(见 migration-plan §2.5)

六、引用清理机制

6.1 三层防护

┌─────────────────────────────────────────────┐
│ 第一层:fileEntryId CASCADE                   │
│ 文件条目删除 → file_ref 自动级联删除          │
├─────────────────────────────────────────────┤
│ 第二层:业务删除钩子                          │
│ 业务对象删除时主动清理对应 file_ref            │
├─────────────────────────────────────────────┤
│ 第三层:注册式孤儿扫描                        │
│ 后台任务扫描 sourceId 不存在的 file_ref        │
└─────────────────────────────────────────────┘

6.2 第一层:fileEntryId CASCADE

fileRefTable.fileEntryId 外键 onDelete: 'cascade' 在 Schema 中已定义。文件条目被永久删除 → 其所有 file_ref 自动删除,无需应用层代码。

6.3 第二层:业务删除钩子

业务 Service 在 delete 路径调用:

typescript
// 单条
await fileRefService.cleanupBySource(sourceType, sourceId);

// 批量(如删除 topic 时一次性清理所有消息的引用)
await fileRefService.cleanupBySourceBatch(sourceType, sourceIds);

接入点

删除场景清理调用
删除消息cleanupBySource('chat_message', messageId)
删除 topiccleanupBySourceBatch('chat_message', messageIds)
删除知识库cleanupBySourceBatch('knowledge_item', itemIds)
删除知识库条目cleanupBySource('knowledge_item', itemId)
删除 paintingcleanupBySource('painting', paintingId)

6.4 第三层:注册式孤儿扫描

typescript
interface SourceTypeChecker {
  sourceType: FileRefSourceType;
  /** 给一批 sourceId,返回其中仍然存在的 ID 集合 */
  checkExists: (sourceIds: string[]) => Promise<Set<string>>;
}

/**
 * 编译期强制:每个 FileRefSourceType 都必须有 checker。
 * 新增 sourceType 未注册 → TypeScript 报错。
 */
type OrphanCheckerRegistry = Record<FileRefSourceType, SourceTypeChecker>;

class OrphanRefScanner {
  constructor(private checkers: OrphanCheckerRegistry) {}

  /** 扫描一种 sourceType 的孤儿引用,cursor-based 分页 */
  async scanOneType(sourceType: FileRefSourceType): Promise<number>;

  /** 扫描所有已注册的 sourceType */
  async scanAll(): Promise<{
    total: number;
    byType: Partial<Record<FileRefSourceType, number>>;
  }>;
}

注册示例(编译期强制覆盖所有 sourceType):

typescript
const orphanScanner = new OrphanRefScanner({
  chat_message: {
    sourceType: "chat_message",
    checkExists: async (ids) => {
      const rows = await db
        .select({ id: messageTable.id })
        .from(messageTable)
        .where(inArray(messageTable.id, ids));
      return new Set(rows.map((r) => r.id));
    },
  },
  knowledge_item: {
    sourceType: "knowledge_item",
    checkExists: async (ids) => {
      /* ... */
    },
  },
  painting: {
    sourceType: "painting",
    checkExists: async (ids) => {
      /* ... */
    },
  },
  // 新增 FileRefSourceType 未补上 checker → TypeScript 编译报错
});

触发时机

  • 应用启动后延迟 30 秒(Background phase,低优先级)
  • 每种 sourceType 间隔 5 秒处理,避免阻塞主进程
  • 用户可在设置页面手动触发"清理无效引用"

6.5 无引用文件的处理

策略:文件保留,用户手动管理

  • 无引用不代表用户不需要(可能备用或手动浏览)
  • 文件页可显示"未引用"标记,供批量清理
  • 不自动移入 Trash,避免用户困惑

七、API 层设计

7.1 DataApi(只读,纯 SQL,固定 shape)

位于 src/shared/data/api/schemas/files.ts。所有端点只做 SQL——不触 FS、不调 main-side resolver、不查 in-memory cache。响应 shape 按端点固定,不使用 opt-in 派生字段。任何需要 FS IO 或 main-side 计算的派生都搬到 File IPC(§7.2)。

typescript
export interface FileSchemas {
  "/files/entries": {
    GET: {
      query: {
        origin?: "internal" | "external";
        inTrash?: boolean;
        sortBy?: "name" | "createdAt" | "updatedAt" | "size";
        sortOrder?: "asc" | "desc";
        page?: number;
        limit?: number;
      };
      response: OffsetPaginationResponse<FileEntry>;  // 固定 shape
    };
  };

  "/files/entries/:id": {
    GET: {
      params: { id: FileEntryId };
      response: FileEntry;  // 固定 shape
    };
  };

  "/files/entries/ref-counts": {
    GET: {
      query: { entryIds: FileEntryId[] };
      response: FileEntryRefCount[];  // { entryId, refCount }[] 纯 SQL 聚合
    };
  };

  "/files/entries/:id/refs": {
    GET: { params: { id: FileEntryId }; response: FileRef[] };
  };

  "/files/refs": {
    // 按业务源过滤 —— query 走 z.strictObject,sourceType / sourceId 均必填
    GET: {
      query: { sourceType: string; sourceId: string };
      response: FileRef[];
    };
    // 不暴露 POST / DELETE —— ref 写操作由业务 service 直接调 fileRefService
  };
}

旧 opt-in 派生字段迁移表(全部搬出 DataApi,分别落到专用端点或 File IPC):

旧 opt-in新归属类别
includeRefCountDataApi 专用端点 /files/entries/ref-counts纯 SQL 聚合(仍 DataApi)
includeDanglingFile IPC getDanglingState / batchGetDanglingStatesFS-backed
includePathFile IPC getPhysicalPath / batchGetPhysicalPathsMain-side resolver
includeUrl共享纯函数 toSafeFileUrl(path, ext)@shared/file/urlUtil)在进程内合成Pure formatting + 危险扩展包装(零 IPC)

7.2 File IPC(读写)

位于 src/shared/file/types/ipc.ts。所有涉及 FS 或 mutation 的操作走此通道。

方法入参返回说明
select对话框选项string | string[] | nullElectron file/folder picker
save{ content, defaultPath?, filters? }string | nullSave dialog + 写文件
createInternalEntryCreateInternalEntryIpcParamsFileEntry新建 Cherry 拥有 entry,每次产生新 UUID,无冲突
ensureExternalEntryEnsureExternalEntryIpcParamsFileEntryexternalPath 纯 upsert:reuse / insert;external 行 size=null,live 值用 getMetadata
batchCreateInternalEntriesCreateInternalEntryIpcParams[]BatchOperationResult批量新建 internal
batchEnsureExternalEntriesEnsureExternalEntryIpcParams[]BatchOperationResult批量 upsert external(批内 path 重复会 coalesce)
readFileHandle, opts?ReadResult<T>读内容(text / base64 / binary)
getMetadataFileHandlePhysicalFileMetadata活物理元数据(fs.stat)。external 条目的 live size / mtime 只能从这里取 —— DB 不存 external size
getVersionFileHandleFileVersion轻量版本戳(fs.stat-backed,两种 origin 都是实时值)
getContentHashFileHandlestringxxhash-128
writeFileHandle, dataFileVersion原子写
writeIfUnchangedFileHandle, data, versionFileVersion乐观并发写
trash{ id }void软删(DB only)。Internal-only — 传 external id 抛错(fe_external_no_delete CHECK)
restore{ id }FileEntry从 Trash 恢复。Internal-only — external 恒非 trashed,传 external id 抛错
permanentDeleteFileHandlevoid删 entry。Internal: unlink FS + 删 DB 行;External (managed): 只删 DB 行,物理文件不动;Unmanaged path: ops.remove(path) 物理删除
batchTrash / batchRestore批量参数BatchOperationResult批量版本,internal-only
batchPermanentDelete批量参数BatchOperationResult批量 permanentDelete(物理影响按上述 origin 规则)
renameFileHandle, newTargetFileEntry | void重命名
copy{ source, newName? }FileEntry复制为新 internal entry
open / showInFolderFileHandlevoid系统程序打开 / 资源管理器定位
listDirectoryFilePath, options?string[]扫描目录
isNotEmptyDirFilePathboolean目录非空检查
getDanglingState / batchGetDanglingStates{ id } / { ids }DanglingState / Record<id, DanglingState>查询 external entry 存在状态(DanglingCache + 冷路径 fs.stat)。Internal 恒 'present'
getPhysicalPath / batchGetPhysicalPaths{ id } / { ids }FilePath / Record<id, FilePath>主进程 resolvePhysicalPath(entry)。用于 agent / drag-drop / subprocess

详细类型契约见 src/shared/file/types/ipc.ts

7.3 Renderer 使用示例

新模式:DataApi 拉纯 SQL 数据 + File IPC 按需补 FS/resolver 派生,renderer 端组合。每个 enrichment 对应一个独立的 useQuery,成本显式可见。

typescript
// 案例 1:FilesPage 列表 + 引用计数 + dangling + preview URL
const { data: entries } = useQuery(fileApi.listEntries, { origin: "internal" });
const entryIds = entries?.map((e) => e.id) ?? [];

const { data: refCounts } = useQuery(fileApi.refCounts, { entryIds });
const { data: presence } = useQuery(
  ["fileManager.batchGetDanglingStates", entryIds],
  () => window.api.fileManager.batchGetDanglingStates(entryIds),
  { enabled: entryIds.length > 0 }
);
const { data: paths } = useQuery(
  ["fileManager.batchGetPhysicalPaths", entryIds],
  () => window.api.fileManager.batchGetPhysicalPaths(entryIds),
  { enabled: entryIds.length > 0 }
);
// renderer 合并后按 refCount 排序
// URL 在进程内合成(共享纯函数,零 IPC):
//   
// dangling 标记:presence?.[entry.id]

// 案例 2:Agent compose 需要绝对路径(复用同一 IPC,不同 consumer)
const { data: entries } = useQuery(fileApi.listEntries, { ids: selectedFileIds });
const { data: paths } = useQuery(
  ["fileManager.batchGetPhysicalPaths", selectedFileIds],
  () => window.api.fileManager.batchGetPhysicalPaths(selectedFileIds)
);
const filePaths = selectedFileIds.map((id) => paths?.[id]).filter(Boolean).join("\n");

// 案例 3:写操作(走 File IPC)
// createInternalEntry 按 source 分支调用,字段类型门自动收紧
await window.api.file.createInternalEntry({
  source: "path",
  path: userPickedPath,
});
await window.api.file.createInternalEntry({
  source: "base64",
  data: dataUrl,
  name: "Pasted Image",
});
await window.api.file.createInternalEntry({ source: "url", url: downloadUrl });
await window.api.file.ensureExternalEntry({ externalPath });
await window.api.file.trash({ id });

八、迁移策略

8.1 迁移的两条主线

主线含义文档
数据层一次搬运Dexie db.files → SQLite file_entry(保 ID)本章
字段级退役 + 消费域切换FileMetadata 字段逐个退役;消费者按域迁移migration-plan.md

8.2 FileMigrator

typescript
class FileMigrator extends BaseMigrator {
  readonly id = "file";
  readonly name = "File Migration";
  readonly description = "Migrate files from Dexie to file_entry table";
  readonly order = 2.7; // After Agents(2.5), Before Knowledge(3)
}

执行顺序(见 migrators/*.ts 现有 order 编排):

BootConfig(0.5) → Preferences(1) → MiniApp(1.2) → Mcp(1.5) → Assistant(2)
  → Agents(2.5) → File(2.7) → Knowledge(3) → Chat(4)
                    ↑ 新增(必须早于所有引用 FileEntry 的业务 migrator)
  • FileMigrator 在 Knowledge 和 Chat 之前运行,确保文件条目已就绪
  • 后续迁移器(Knowledge、Chat)可以创建各自的 file_ref 记录
  • PaintingMigrator 不在本次范围内,随 Painting 业务重构独立推进

8.3 三阶段流程

Prepare:检查 Dexie files 表存在性 + 计数 + 样本字段校验。

typescript
async prepare(ctx: MigrationContext): Promise<PrepareResult> {
  const hasFiles = await ctx.sources.dexieExport.tableExists('files')
  if (!hasFiles) return { success: true, itemCount: 0 }

  const reader = ctx.sources.dexieExport.createStreamReader('files')
  const count = await reader.count()

  const sample = await reader.readSample(10)
  const warnings: string[] = []
  for (const file of sample) {
    if (!file.id || !file.origin_name) {
      warnings.push(`File ${file.id} missing required fields`)
    }
  }

  return { success: true, itemCount: count, warnings }
}

Execute

typescript
async execute(ctx: MigrationContext): Promise<ExecuteResult> {
  const BATCH_SIZE = 100
  const reader = ctx.sources.dexieExport.createStreamReader('files')
  const totalCount = await reader.count()
  let processed = 0
  const fileIdMap = new Map<string, string>() // oldId → newId (1:1, ID 保留)

  await reader.readInBatches(BATCH_SIZE, async (batch) => {
    const entries = batch.map((old) => this.transformFile(old))
    await ctx.db.insert(fileEntryTable).values(entries)
    for (const entry of entries) {
      fileIdMap.set(entry.id, entry.id)
    }
    processed += batch.length
    this.reportProgress(
      Math.round((processed / totalCount) * 100),
      `Migrated ${processed}/${totalCount} files`,
      { key: 'migration.progress.files', params: { current: processed, total: totalCount } }
    )
  })

  ctx.sharedData.set('fileIdMap', fileIdMap)
  return { success: true, processedCount: processed }
}

private transformFile(old: DexieFileMetadata): InsertFileEntry {
  const { name, ext } = splitName(old.origin_name || old.name)
  return {
    id: old.id, // 保留原 v4 ID(Schema 已放宽 z.uuid())
    origin: 'internal', // 旧数据全部视为 Cherry 管理
    name,
    ext: (old.ext ?? '').replace(/^\./, '') || null,
    size: old.size ?? 0,
    externalPath: null,
    deletedAt: null,
    createdAt: new Date(old.created_at).getTime(),
    updatedAt: new Date(old.created_at).getTime()
  }
}

关键要点

  • ID 保留FileMetadata.id → file_entry.id(1:1),所有引用该 ID 的地方(message blocks fileId、knowledge items content.id、painting files[*].id零翻译
  • origin='internal':旧数据全部视为 Cherry 管理(旧架构无 external 概念)
  • 物理文件不移动:旧路径 {userData}/Data/Files/{id}{ext} 与新路径 {userData}/files/{id}.{ext} 可能存在微差(含点/不含点),在 resolvePhysicalPath / 启动期兼容逻辑内处理(详见 migration-plan §2.7.6)
  • ext normalize:去除前导点;无扩展名为 null

Validate:对比 Dexie 源表行数与 file_entry.origin='internal' 计数。

typescript
async validate(ctx: MigrationContext): Promise<ValidateResult> {
  const reader = ctx.sources.dexieExport.createStreamReader('files')
  const sourceCount = await reader.count()

  const [{ count: targetCount }] = await ctx.db
    .select({ count: sql<number>`count(*)` })
    .from(fileEntryTable)
    .where(eq(fileEntryTable.origin, 'internal'))

  const errors: ValidationError[] = []
  if (sourceCount !== targetCount) {
    errors.push({
      key: 'file_count_mismatch',
      expected: sourceCount,
      actual: targetCount,
      message: `Expected ${sourceCount} files, found ${targetCount}`
    })
  }

  return {
    success: errors.length === 0,
    errors,
    stats: { sourceCount, targetCount, skippedCount: sourceCount - targetCount }
  }
}

8.4 其他 Migrator 的 file_ref 创建

KnowledgeMigrator(order=3)

typescript
const fileIdMap = ctx.sharedData.get("fileIdMap") as Map<string, string>;

if (item.type === "file" && item.content?.id) {
  if (fileIdMap.has(item.content.id)) {
    await ctx.db.insert(fileRefTable).values({
      id: generateUUIDv7(),
      fileEntryId: item.content.id,
      sourceType: "knowledge_item",
      sourceId: newKnowledgeItemId,
      role: "source",
    });
  } else {
    logger.warn(`Skipping file_ref: entry ${item.content.id} not found`);
  }
}

ChatMigrator(order=4)— 延后

状态:ChatMigrator 的 file_ref 创建不在 Batch 0 范围内(PR #15067 已显式 defer),随 chat 域整体迁移到 v2 file_ref 服务时一并落地。下面的设计草案保留供后续 PR 参考。

延后理由chat_message 当前不是已注册的 FileRefSourceType(见 src/shared/data/types/file/ref/index.tsallSourceTypes,目前只含 temp_session / knowledge_item)。按 RFC 「三表面同步」规则,新增一个 sourceType 必须在同一 PR 内一并落地(a)allSourceTypes tuple 项、(b)对应 createRefSchema variant、(c)OrphanRefScanner 里的 SourceTypeChecker。这三处与 chat 域的 file_ref 消费服务绑定,应整体推进,而不是夹在 Batch 0 数据搬运 PR 里。

当前可达性(延后期间):v1 image / file block 的 block.file.id 已经被 ChatMigrator 透传为 v2 ImageBlock.fileId / FileBlock.fileId(写在 messageTable.data.blocks 的 inline JSON 里)。chat 消息访问附件文件通过该 inline 字段——无数据丢失。仅缺反向索引行((sourceType='chat_message', sourceId, fileEntryId));依赖 file_ref 反查 "哪些 message 引用了这个文件" 的特性在 chat 域 file_ref service 落地前会返回空集。

未来设计(保留参考):迁移 message blocks 时,block.type === 'file' | 'image' 且含 fileId → 创建 sourceType='chat_message' 的 ref。

容错要求:旧数据中可能存在 block.fileId 指向已被删除文件的情况(悬挂引用)。由于 fileRefTable.fileEntryId 有 FK 约束,直接插入会失败。因此必须先验证存在性,缺失跳过并记录 warning。

typescript
const fileIdMap = ctx.sharedData.get("fileIdMap") as Map<string, string>;

if ((block.type === "file" || block.type === "image") && block.fileId) {
  if (fileIdMap.has(block.fileId)) {
    fileRefsToInsert.push({
      id: generateUUIDv7(),
      fileEntryId: block.fileId,
      sourceType: "chat_message",
      sourceId: messageId,
      role: "attachment",
    });
  } else {
    logger.warn(`Skipping file_ref: entry ${block.fileId} not found`);
  }
}

8.5 Painting 迁移(延后)

Paintings 数据存储在 Redux state 中(PaintingParams.files: FileMetadata[])。

决策:PaintingMigrator 不在本次范围内,随 Painting 业务重构独立推进。

  • 唯一依赖:FileMigrator 已将文件条目写入 fileEntryTable(保留原 ID),PaintingMigrator 可直接用 FileMetadata.id 作为 fileEntryId 创建 file_ref
  • 在 PaintingMigrator 实现之前,painting 引用的文件不会有 file_ref 记录,但文件条目本身已存在且可访问
  • sourceType: 'painting' 已纳入 OrphanRefScanner 的注册式设计,PaintingMigrator 上线后自动覆盖

8.6 回滚策略

场景方案
FileMigrator 失败MigrationEngine 标记失败,用户可重试。清空 file_entry(origin='internal' 部分)重跑
迁移完成后数据异常Dexie 导出文件(files.json)保留,可重建
新旧并行期数据不一致toFileMetadata 适配函数(见 migration-plan §4)保证旧消费方继续工作
物理文件丢失迁移不移动物理文件,路径兼容性在 resolver 内处理,无文件丢失风险

8.7 字段级退役 + 消费域切换

详见 migration-plan.md §2(字段退役)与 §3(Batch A-E 消费域切换)。本 RFC 不重复。


九、分阶段实施计划

9.1 总览

Phase 1a ──→ Phase 1b.1 ──→ Phase 1b.2 ──→ Phase 1b.3 ──→ Phase 1b.4 ──→ Phase 2 ──→ (业务 PRs)
(契约+骨架)   (读路径)        (写/生命周期)   (监控+悬挂)    (启动一致性)    (消费方迁移)
 零运行时      repo + ops       versionCache   watcher +      orphanSweep +               │
                read + canon.    + mutations    DanglingCache  Ref checker                 └──→ Phase X (AI SDK upload)

每个 1b.x 作为独立可合入 PR。上游(1b.1)合入后,renderer 可以按能力 opt-in 切换新路径;后续阶段 additive 扩展,互不阻塞。

实际开发偏离(2026-05):Phase 1a + 1b.1/1b.2/1b.3/1b.4 全部合并到单个 PR feat(file): Add schema and foundation for new file module (#13451)。当前 PR 的实际 scope 是整个 Phase 1;§9.2-§9.6 的子阶段划分保留为概念边界与 commit-level 分组参考,但不再对应独立 PR。Phase 2 仍按 §9.7 分批走。下文「不在本期」与「依赖」字段读作"在 Phase 1 内的相对顺序",而非"独立 PR 的发布边界"。

关于 §9.3 / §9.4 中 ops/*@main/utils/file/* 函数清单的来源:v1 src/main/utils/file.ts(现 legacyFile.ts)和 src/main/utils/fileOperations.ts 里每个导出函数应该如何拆解到 v2 @main/utils/file/{fs,metadata,path,search,shell} 或其它新位置、各自在哪个 phase 落地,完整规划见 utils-file-migration.md。本 RFC 不重复函数级清单。

9.2 Phase 1a:Contract、Schema、Skeleton(零运行时)

职责边界:只定义类型契约数据库 schema接口骨架不含任何业务逻辑实现——method body 一律 throw(旧措辞为 'not implemented in Phase 1a';合并到单 PR 后落地为 'deferred to Phase 2'),所有 ops 纯函数、FileManager public API、IPC handler、DataApi handler 只保留签名 + JSDoc 契约。原始的 Phase 1a 成功标准是「能让 Phase 1b.x 的子 PR 各自独立合入」;合并到单 PR 后,1a 的成功标准退化为"1b.x runtime 实现的 type/接口基线"。

交付物

类别内容
DB Schemasrc/main/data/db/schemas/file.tsfileEntryTable + fileRefTable,全部 CHECK 约束(fe_origin_consistency / fe_external_no_delete / fe_size_internal_only)就位
DB migrationpnpm agents:generate 生成的 SQL
跨进程类型src/shared/data/types/file/ DTO(FileEntry brand DU / FileRef / DanglingState 等);src/shared/data/api/schemas/files.ts DataApi schema 声明
File 类型src/shared/file/types/ipc.ts File IPC 契约;src/shared/file/types/handle.ts FileHandle tagged union + factory;src/shared/file/types/info.ts FileInfo + toFileInfo declare only
Source 枚举FileRefSourceType 扩成完整 literal union('chat_message' | 'knowledge_item' | 'painting' | 'note' | 'temp_session')——Phase 1b.4 加 checker 时缺项会编译期爆
Main 骨架src/main/file/index.ts barrel;src/main/file/ops/* 纯函数签名 + JSDoc + throw NotImplementedsrc/main/file/FileManager.ts lifecycle service 骨架;src/main/file/danglingCache.ts / watcher/index.ts / internal/deps.ts interface
运行时实现 pathResolver.resolvePhysicalPath + getExtSuffix(含 null-byte 防御、9 条边界测试)
DataApisrc/main/data/api/handlers/files.ts — read-only endpoint 允许占位(返回 stub / NotImplemented)
文档architecture.md / file-manager-architecture.md 全文 Phase badge;RFC 本章 Phase 准入门槛

出口条件

  • pnpm lint + pnpm build:check 通过
  • src/main/file/ 下的 interface 与 ops 签名能被 Phase 1b.x 子 PR 独立 import,无循环依赖
  • renderer 编译通过(但不走 Phase 1a handler;IPC 调用 Phase 1a 时 throw 是 acceptable)
  • 骨架文件原计划包含显式 // [Phase 1b.x] TODO: 注释;合并到单 PR 后这些 TODO 在 1b.x 实现落地时一并删除,仅 Phase 2 deferred 的 stub(fs.compressImage / path.resolvePath / path.isNotEmptyDir / shell.open / shell.showInFolder / search.listDirectory)保留 TODO(phase-2) 注释 + throw new Error('… deferred to Phase 2')

不在本期

  • FileManager / ops / internal / watcher / danglingCache / orphanSweep 任何运行时逻辑
  • canonicalizeAbsolutePath 实现(契约与签名 Phase 1a 锁定,实现在 1b.1)
  • versionCache 运行时(只定义 interface)
  • 任何 Dexie → SQLite 数据搬运
  • renderer 切换调用路径

依赖:无(可独立 merge)

9.3 Phase 1b.1:Read Path & Repository

职责边界:填充「数据仓库 + 读路径」的 runtime——使 renderer 能通过新架构读到文件条目。零写入、零生命周期变更。

交付物

  • FileEntryService / FileRefService CRUD 实现(纯 DB;read 路径完整,write 可保留 stub)
  • ops/fs.tsread / stat / exists / metadata / contentHash(xxhash-128)
  • ops/path.tsresolvePhysicalPath(已存在)
  • canonicalizeAbsolutePath 真实现(path.resolve + NFC + trailing-sep strip)+ 8-10 条边界测试(NFC/NFD / trailing / ./a/../a / Windows \\ / 盘符大小写)
  • FileManager.get* / read* / getMetadata / getUrl / findByExternalPath / ensureExternalEntry(upsert-only,不写 FS)
  • internal/content/read.ts / internal/content/hash.ts(含 *ByPath 变体)
  • dispatchHandle(handle, byEntryFn, byPathFn) helper 的读路径分派骨架
  • DataApi 只读 endpoint 全部上线
  • 单测:ops/* 纯函数 + service repo + setupTestDatabase() schema 不变量验证

出口条件

  • renderer 可通过 FileEntryHandle 查询 entry + 读内容
  • external path 大小写/NFC 差异不产生双 entry(在 case-sensitive FS 下)
  • 文件页原有读路径可 feature-flag 切到新架构,旧路径仍存

不在本期

  • 写 FS / rename / copy / trash / restore / permanentDelete
  • versionCache 运行时(interface 维持骨架)
  • watcher / DanglingCache / orphanSweep

依赖:Phase 1a

9.4 Phase 1b.2:Write Path & Lifecycle

职责边界:填充「所有 mutation」——文件写入(含 OCC 防护)、条目生命周期(trash/restore/permanentDelete)、条目物理操作(rename/copy/refresh)。

交付物

  • VersionCache 实现 + 跨进程可见性决策(per-process LRU,进程间不共享)
  • FileVersion 精度 fallback 运行时落实:mtime 秒级 + size 未变时 content-hash 回退
  • ops/fs.atomicWriteFile / atomicWriteIfUnchanged / createAtomicWriteStream(tmp + rename,失败回滚)
  • ops/fs.tswrite / copy / move / remove / open / showInFolder / listDirectory(ripgrep + 模糊)
  • internal/entry/create.tscreateInternal / ensureExternal(write 分支)
  • internal/entry/lifecycle.tstrash / restore / permanentDelete + batch 变体(permanentDelete 解耦物理 —— DB 删 row 与 FS 删文件分两步)
  • internal/entry/rename.ts / copy.ts / refresh.ts
  • internal/content/write.ts(含 *ByPath 变体)
  • internal/system/shell.ts / tempCopy.ts
  • FileManager facade 全部 mutation API + dispatchHandle 写路径分派
  • 单测:atomic 失败回滚、OCC 误判场景(同秒+同 size)、trash/restore/permanentDelete 的 CHECK 约束

出口条件

  • renderer 可完整走 FileManager 做增删改
  • external entry 的 trash 调用被 DB CHECK 阻断(fe_external_no_delete
  • 写失败时物理文件零残留(atomic 保证)
  • writeIfUnchanged 在同秒+同 size 场景用 content-hash 回退,不误判

不在本期

  • watcher / DanglingCache(外部变更感知)
  • orphanSweep(启动期一致性检查)

依赖:Phase 1b.1

9.5 Phase 1b.3:Watcher & DanglingCache(可观测性)

职责边界:对外部文件变更的感知——watcher 作为事件源,DanglingCache 作为可订阅的状态聚合。

交付物

  • createDirectoryWatcher primitive 实际实现(chokidar 或等价),含 debounce / 去重
  • DanglingCache 反向索引实现(externalPath → entryId set)
  • watcher 事件自动接入 DanglingCache 状态更新
  • File IPC getDanglingState / batchGetDanglingStates 落地(DataApi 不承载 dangling 查询)
  • FileManager.subscribeDangling 订阅 API(future:push-based 失效通知)
  • 单测:watcher 事件→DanglingCache 状态转换、反向索引增删一致性、订阅清理

出口条件

  • external entry 物理消失 → DanglingState'ok''missing'
  • 文件页能订阅并展示 dangling 状态
  • DanglingCache.'unknown' 在启动未完成索引时的行为与文档一致(consumer MUST 视为 not-actionable)

不在本期

  • 启动期全量孤儿扫描(独立于 per-path watcher)
  • 自动清理 file_ref(用户手动处理)

依赖:Phase 1b.2

9.6 Phase 1b.4:OrphanSweep & FileRefCheckerRegistry(启动期一致性)

职责边界:启动期一次性「数据一致性 sweep」—— orphan entry(无任何 file_ref 指向)扫描与 bucket P consumers 的 ref checker 注册。

交付物

  • internal/orphanSweep.ts 实现
  • FileManager.onInit 的 fire-and-forget sweep
  • src/main/data/services/orphan/FileRefCheckerRegistry.ts 实现
  • 覆盖 bucket P consumers 的 checker(chat_message / knowledge_item / painting,见 filemetadata-consumer-audit.md
  • sweep 结果 metric + 日志(不自动删除,只汇总 + 暴露给清理 UI)
  • 单测:orphan 识别、checker 注册完备性(Record<FileRefSourceType, SourceTypeChecker> 强制所有变体)

出口条件

  • 启动能识别所有 orphan entry 并产生报告
  • 新增 FileRefSourceType variant 时 checker 缺失会编译期爆
  • RFC §6 注册式 checker 设计完全落地

不在本期

  • 孤儿自动清理(须用户确认;UI 在 Phase 2 业务 PR)
  • fs.realpath case-insensitive FS 去重(见风险表,Phase 2 additively)

依赖:Phase 1b.3

9.7 Phase 2:FileMigrator + 消费方迁移(分多 PR)

先落 FileMigrator(§8),将 Dexie db.files 一次性搬到 file_entry;随后按 migration-plan.md §3 的 Batch A-E 推进:

  • Batch 0:FileMigrator(数据层一次搬运,包括 KnowledgeMigrator 内新增的 file_ref 创建)。ChatMigrator file_ref 创建延后到 chat 域整体迁移(见 §8.4 ChatMigrator 延后说明);PaintingMigrator 同样延后(见 §8.5)
  • Batch A:数据层适配(toFileMetadata 适配 + 旧 FileMetadata 标注 @deprecated
  • Batch B:AI Core(fileProcessor / messageConverter / API 客户端)
  • Batch C:Knowledge + Painting
  • Batch D:UI + state management(文件页、消息 block、绘图页面、messageThunk、knowledgeThunk)
  • Batch E:清理(移除 Dexie files 表、FileMetadata 类型、旧 FileStoragetoFileMetadata 适配)

每个 Batch 完成后:运行 pnpm build:check(lint + test + typecheck),确保不引入回归。

依赖:Phase 1b.4

9.8 Phase X:AI SDK Upload(延后独立 PR)

Vercel AI SDK Files API 稳定后:

  • file_upload 表 additive migration
  • FileUploadService lifecycle service + FileUploadRepository
  • ensureUploaded / buildProviderReference / invalidate 方法
  • 设计意图见 file-manager-architecture.md §9

十、取舍记录

取舍结论权衡
内容去重放弃优点:用户视角每文件独立;代价:磁盘占用增加、无 COW 复用。影响:count 字段退役,逻辑简化
目录树持久化层不做优点:schema 简洁;代价:文件页无 in-app 树。缓解:primitive 层预留 DirectoryTreeBuilder(§十二)供业务按需消费
Notes 耦合解耦Notes 自治 FS-first;跨域引用用 origin='external' FileEntry
UUID 版本新 entry 用 v7;旧 v4 保留v7 的 time-order 只对新 insert 有意义;保留 v4 避免跨表翻译(migration-plan §2.9)
External 操作策略用户显式操作可改,不追踪外部 rename类 VS Code 语义;外部 rename 让 entry 自然 dangling
AI SDK upload延后独立 PR依赖未稳定;FileEntry schema 不受影响
count 字段退役改由 DataApi 专用端点 /files/entries/ref-counts 按需 SQL 聚合(migration-plan §2.3)
type 字段不持久化查询时 ext 派生;getMetadata 可 buffer 升级(migration-plan §2.5)
purpose 字段退役业务上是 upload 调用参数,不是文件属性(migration-plan §2.2)
tokens 字段纯删0 producer + 0 consumer 的死字段(migration-plan §2.4)

十一、风险项

风险影响缓解
FileMetadata 引用面广(274+ 处)Consumer Migration 工作量大toFileMetadata 适配 + 分批 Batch A-E 迁移(migration-plan §3)
ext 含点/不含点不统一路径解析错误迁移时 normalize 为不含点;resolvePhysicalPath 拼接时始终加点(migration-plan §2.7.6)
KnowledgeMigrator / ChatMigrator 的 fileId 可能悬挂插入 file_ref 失败先查 fileIdMap 验证存在性,缺失跳过 + warn
Painting 的 file_ref 暂缺文件页无法追溯 painting 引用文件条目本身已存在可访问;随 Painting 重构补建
Phase 1 内 deferred-to-Phase-2 stub 的 throw NotImplemented 影响上游开发期阻塞Phase 1 不切换 renderer 调用路径;剩余 stub(fs.compressImage / path.resolvePath / path.isNotEmptyDir / shell.open / shell.showInFolder / search.listDirectory)在 Phase 2 各自消费方迁移时一并实现并 feature-flag 切换
External entry 物理文件外部丢失entry 变 danglingDanglingCache + File IPC getDanglingState / batchGetDanglingStates 给 UI 展示;不自动清理 file_ref(用户手动处理)
externalPath 大小写不敏感 FS 导致同文件双 entry(macOS APFS / Windows NTFS)文件页用户看到两份同文件、file_ref 分裂Phase 1b.1 canonicalizeAbsolutePath 做同步廉价规范化(resolve + NFC + trailing-sep),刻意不做 fs.realpath case 去重——支配性来源(dialog / drag-drop)本就给 OS-canonical 值;收到真实用户报告后再 additively 扩展 + one-off migration 合并重复行

十二、预留 Primitive:DirectoryTreeBuilder

状态:已被取代(SUPERSEDED). 本节是早期草案,落地实现已在 PR #15363 完成,并以 docs/references/file/directory-tree.md 作为唯一的架构权威源。下文保留为历史记录,不再维护。变更要点:

  • 物理路径:草案写的 src/main/file/tree/ → 实际落到 src/main/services/file/tree/,作为 file module 内与 FileManager 并列的顶级 primitive。
  • IPC 命名:草案 Tree_* → 实际 File_Tree*(file:tree:create / file:tree:dispose / file:tree:mutation),归入 file: scope。
  • 类层级:TreeNode / TreeFile / TreeDir / TreeDirRoot 落在 src/shared/file/types/tree.ts,主进程与渲染进程共享。
  • 渲染端:配套 hook useDirectoryTree(rootPath, options),首个消费者为 Notes(NotesPage)。

v2-refactor-temp/ 目录整体计划在 v2 发布前删除;本节的设计意图与最终实现的差异以上方 SoT 为准。

历史草案:接口草案,不在当前 Phase 实现范围。首个实现者(Notes)落地时产出 lean 版本,第二个消费者到来时再抽公共。

12.1 动机

Notes 笔记树、未来可能的 VSCode-like 文件浏览器、知识库目录型 item 视图等,都需要"从某根目录构建一棵可维护的树并随 FS 变更更新"的能力。若每个业务各写一份,会带来重复的事件→mutation 逻辑、各异的过滤规则实现,以及第二消费者出现时昂贵的回迁成本。

方案:在 file module 内预留 DirectoryTreeBuilder 作为 primitive(与 DirectoryWatcherops 同级,位于 src/main/file/tree/),只提供数据层的树构建与维护能力。

12.2 设计边界

属于 primitive

  • 初始扫描:scan(rootPath) → 生成 TreeNode<T>
  • 事件应用:订阅 DirectoryWatcher,按 add / unlink / rename 事件 mutate 树
  • 节点 payload 泛型:TreeNode<T>,业务可扩展 data: T
  • 过滤:可插拔 shouldInclude(path, stat) => boolean 回调

不属于 primitive(留给消费者):

  • UI 状态:选中、展开/折叠、虚拟滚动
  • 懒加载:默认全量 scan;lazy 展开作为后续扩展点
  • 业务 mutation(创建/删除/重命名 FS 文件):消费者调 ops/* 或 FileManager
  • 跨树聚合、搜索高亮、git 状态叠加:上层业务组合

12.3 接口草案

typescript
// src/shared/file/types/tree.ts

export interface TreeNode<T = unknown> {
  path: string; // 绝对路径
  name: string; // basename
  kind: "file" | "directory";
  parent: TreeNode<T> | null;
  children: TreeNode<T>[]; // file 节点为空数组
  data?: T; // 业务侧扩展
}

export interface DirectoryTreeOptions<T = unknown> {
  /** 过滤:返回 false 的路径不纳入树(同时传给 watcher 的 ignored 避免噪声) */
  shouldInclude?: (path: string, stat: { isDirectory: boolean }) => boolean;
  /** 初始化节点 payload */
  initNodeData?: (node: Omit<TreeNode<T>, "data">) => T;
  /** 透传给底层 DirectoryWatcher */
  watcherOptions?: Partial<DirectoryWatcherOptions>;
}

export type TreeMutationEvent<T> =
  | { type: "added"; node: TreeNode<T>; parent: TreeNode<T> }
  | { type: "removed"; node: TreeNode<T>; parent: TreeNode<T> }
  | {
      type: "renamed";
      node: TreeNode<T>;
      oldPath: string;
      newParent: TreeNode<T> | null;
    };

export interface DirectoryTreeBuilder<T = unknown> extends Disposable {
  readonly root: TreeNode<T>;
  getNode(path: string): TreeNode<T> | null;
  onMutation: Event<TreeMutationEvent<T>>;
}

12.4 工厂与接线

typescript
// src/main/file/tree/factory.ts

export async function createDirectoryTree<T = unknown>(
  rootPath: string,
  options?: DirectoryTreeOptions<T>,
): Promise<DirectoryTreeBuilder<T>>;

工厂内部:

  1. walk rootPath 构建初始树(受 shouldInclude 过滤)
  2. 通过 createDirectoryWatcher() 订阅 FS 事件(复用现有 primitive,自动接入 DanglingCache)
  3. 事件 → 树 mutation 映射:
    • onAdd / onAddDiradded
    • onUnlink / onUnlinkDirremoved
    • onRenamerenamed(启用 renameDetection 时)

12.5 阶段化路线

阶段内容触发条件
A. 接口草案(本节)类型 + 文档已完成
B. Lean 实现scan + watcher 接线 + add/remove/rename mutation;无 lazy、无高级过滤Notes 集成时落地
C. 能力补全lazy 展开、gitignore、diff 推送第二消费者出现且确有需求
D. 公共抽取重构若第二消费者需求与 Notes 分叉严重Phase C 后

12.6 与问题清单的关系

此 primitive 不改变 file-arch-problems-response.md 中 §6 / §9 / §10 的决策:

  • file_entry 表仍然扁平,不引入 parentId
  • Notes 文件仍不镜像到 file_entry
  • 树是运行时 / 渲染层关注点,与持久化模型正交

它只是把"各业务各写一份 tree 逻辑"的潜在重复收敛到 file module primitive,换句话说:把 §6 原问题中的"目录树能力缺失"回应为"primitive 就位,业务按需消费"——而非把目录结构塞回 DB。


十三、待补充内容

  • FileMigrator 对旧物理文件路径的兼容细节 → 已补,见 migration-plan §2.10.2(v1 / v2 物理命名字节相同,FileMigrator 只做 schema 层 ext 归一化 + 抽样验证)
  • FileMigrator 整体规约与跨 migrator 协议 → 已补,见 migration-plan §2.10(位置 / order=2.7 / idRemap & knownIds 跨 migrator 传递 / 失败处理矩阵 / 观测性)
  • 切换期跨模块协调 → 已补,见 migration-plan §3.4(Backup-Restore 协调 / OrphanRefScanner 启动 gate / Dexie files 表 phasing / v1 window.api.file.* 下线顺序 / remotefile/* services 过渡期)
  • PaintingMigrator(随 Painting 业务重构独立推进,仅依赖 FileMigrator 提供的 fileId)
  • DirectoryTreeBuilder Lean 实现细节(随 Notes 集成落地)
  • AI SDK FileUploadService 详细接口(SDK 稳定后独立 PR)
  • External entry path relink(见 §14.1;尚未立项,待真实触发场景)

十四、开放讨论(未立项)

本节记录架构设计中已识别但尚未决策是否落地的问题。每项需出现明确触发场景后再考虑立项;未触发前保持开放状态,不进入任何 Phase 计划。

14.1 External entry path relink

问题:当前所有涉及 external entry externalPath 修正的 API 都以路径为主键,缺一条"以 entry id 为主键、只改指针不触碰 FS、保留所有 file_ref"的通道。

现有 API行为与 relink 需求的差异
rename(external 分支)ops.rename + DB 更新主动物理重命名;若文件已被外部移走、物理原路径不存在,fs.rename 直接 ENOENT
ensureExternalEntry(newPath)upsert by path按路径为主键——新路径匹配不到现有 entry,产生新 id,旧 entry 变 dangling,原 file_ref 全部失联
permanentDelete + ensureExternalEntry先删后增CASCADE 删除 file_ref,所有下游引用(messages、knowledge 等)归零

典型场景:用户 @ 过 ~/Docs/report.pdf,关联 N 条 file_ref → 之后在 Finder 中把文件挪到 ~/Archive/report.pdf。按当前流程,entry 先变 dangling,用户 re-@ 产生新 id,旧 N 条 ref 要么被 OrphanRefScanner 扫掉、要么需用户手动逐条重新 @——任何选择都不理想。

如果立项,建议形态

ts
// File IPC,纯 DB + DanglingCache 同步,不触碰 FS
relinkExternalEntry(id: FileEntryId, newPath: FilePath): Promise<FileEntry>;

语义承诺

  • 不触碰 FS——与 rename 明确区分("追认已移动" vs "主动移动")
  • 保留 id 与所有 file_ref
  • 调用 canonicalizeAbsolutePath 归一化新路径
  • 同步更新 DanglingCache 反向索引(旧 path 移除、新 path 添加、状态重置)
  • name / ext 作为 externalPath 的投影自动跟更新

待决设计点

  1. 路径冲突策略:若 newPath 已被另一 active external entry 占用(externalPath 全局唯一索引),如何处理?
    • (a) 抛错,交由业务层显式 resolve(保守默认)
    • (b) 自动合并两 entry:涉及 file_ref 迁移、哪个 id 胜出、trashed 状态优先级——复杂度高
    • 倾向 (a),保持 relink 语义单一
  2. 通道归属:虽不触碰 FS,但需更新 DanglingCache(DB 外内存缓存)。按 §7.1 DataApi 的 SQL-only 边界,必须走 File IPC,不得做成 DataApi mutation
  3. 与 watcher 的并发:relink 期间若刚好有 watcher 事件在飞(旧 path missing / 新 path added),需保证最终一致——可能需在 relink 内部原子化"反向索引更新 + 状态重置"
  4. 批量形态:是否一并提供 batchRelinkExternalEntries?取决于是否有批量触发场景(如一次性重新定位整个目录下的所有引用)

暂不立项的理由

  • 真实触发频率未知——dangling UI + 用户 re-@ 的当前流程可能已足够覆盖常见用况
  • Phase 1b.2 需先稳定 rename / write 等已确定方法;relink 作为 additive 补充可随时加入,不阻塞任何现有流程

立项触发条件

  • 出现明确的 product-level 场景(如 Notes 集成、外部文件批量导入向导、用户反馈"移动文件后引用丢失")
  • 用户研究发现 re-@ 流程造成非预期的关系损失