Spacedrive Entry 中心化数据模型:用统一 Entry 建模文件、目录与符号链接的 VDFS 核心设计
【免费下载链接】spacedriveSpacedrive is an open source cross-platform file explorer, powered by a virtual distributed filesystem written in Rust.项目地址: https://gitcode.com/gh_mirrors/sp/spacedrive
Spacedrive 的虚拟分布式文件系统(VDFS)以Entry作为一切文件系统对象(文件、目录、符号链接)的统一载体:无论对象种类如何,Entry通过metadata_id外键链接一条UserMetadata记录,使任意文件在索引发现的第一时间就具备打标签、加备注、收藏等元数据能力。本文以任务文档 CORE-001-entry-centric-model.md 为核心骨架,结合core工作区中的领域模型、数据库实体、迁移脚本与同步实现,完整讲解 Entry 模型的字段设计、数据库 Schema、内容身份去重机制与跨设备同步语义,帮助读者理解并复用这套“文件即 Entry”的数据建模思路。
一、从任务到落地:CORE-001 的交付物与仓库路径映射
CORE-001 是 Spacedrive core 任务看板中的一项已交付任务(status: Done,负责人jamiepine,父任务CORE-000,优先级 High),其目标非常明确:
实现通用的
Entry数据模型,它表示任意文件系统条目(文件、目录、符号链接)。每个Entry都通过一条链接的UserMetadata记录具备即时元数据能力,允许用户在文件被发现的瞬间就对其进行打标签与组织。
任务文档给出的三条实现要点与验收标准如下(原文摘录):
- 核心领域模型定义于
src/domain/entry.rs; - 对应数据库实体实现于
src/infrastructure/database/entities/entry.rs; EntryKind枚举正确区分File、Directory、Symlink三种类型;- 验收标准(均已勾选完成):
Entry结构体存在且包含metadata_id与content_id字段;系统能使用统一模型表示文件与目录;数据库 Schema 正确反映Entry模型。
需要说明的是,任务文档撰写时使用的路径是简写形式,在当前仓库的实际布局中,这些模块的落点为:
| 任务文档中的路径 | 仓库中的实际实现 |
|---|---|
src/domain/entry.rs | core/src/domain/file.rs(EntryKind与聚合模型File) |
src/infrastructure/database/entities/entry.rs | core/src/infra/db/entities/entry.rs(SeaORM 实体) |
EntryKind枚举 | 领域层与数据库层各有一份,数值编码一致(见下文) |
此外,领域模型通过 core/src/domain/mod.rs 统一导出:pub use file::{EntryKind, File, Sidecar};与pub use user_metadata::UserMetadata;,供上层 ops、service 与前端 SDK 直接引用。
二、Entry 统一模型:一个模型表达文件、目录与符号链接
2.1 领域层的EntryKind枚举
在 core/src/domain/file.rs 中,领域层定义了三值枚举,并通过serde与specta::Type派生,使其既能序列化传输,也能自动生成 TypeScript 类型供前端使用:
/// Type of filesystem entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] pub enum EntryKind { /// Regular file File, /// Directory Directory, /// Symbolic link Symlink, }数据库实体层在 core/src/infra/db/entities/entry.rs 中定义了数值编码完全一致的持久化枚举,并实现i32与枚举的双向转换,任何未知值都安全回退为File:
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EntryKind { File = 0, Directory = 1, Symlink = 2, }领域层在将数据库模型转换为File时也按同一映射处理(见File::from_entity_model,core/src/domain/file.rs):0 → File、1 → Directory、2 → Symlink。目录、文件、符号链接从此共享同一张表、同一套索引、同一套同步通道,而无需为每种对象维护独立模型——这正是“Entry 中心化”的核心收益。
2.2 聚合模型File:Entry 的“开发者友好面”
Entry只是持久化骨架,面向业务与前端的是聚合模型File(core/src/domain/file.rs)。它是计算型领域模型:不重复存储数据,而是把Entry、ContentIdentity、Tag、Sidecar、媒体元数据在查询时一次性组装:
pub struct File { pub id: Uuid, pub sd_path: SdPath, // VDFS 通用路径 pub kind: EntryKind, // 文件 / 目录 / 符号链接 pub name: String, pub extension: Option<String>, // 不含点号 pub size: u64, pub content_identity: Option<ContentIdentity>, // 内容哈希身份,用于去重 pub alternate_paths: Vec<SdPath>, // 共享同一内容的其它路径(副本) pub tags: Vec<Tag>, // 语义标签 pub sidecars: Vec<Sidecar>, // 侧车文件 pub image_media_data: Option<ImageMediaData>, pub video_media_data: Option<VideoMediaData>, pub audio_media_data: Option<AudioMediaData>, pub created_at: DateTime<Utc>, pub modified_at: DateTime<Utc>, pub accessed_at: Option<DateTime<Utc>>, // ... }File实现Identifiabletrait(core/src/domain/file.rs),声明了同步依赖链:entry、content_identity、sidecar、image_media_data、video_media_data、audio_media_data、user_metadata、user_metadata_tag、tag。这意味着当任一条依赖(例如标签)发生变化时,资源系统可以沿依赖图反查受影响的文件并重发事件。其中与 Entry 直接相关的路由模式有:
- 直接映射:
"entry"依赖下,File ID = Entry UUID; - 经内容身份扇出:
"content_identity"依赖下,先按 UUID 找到内容身份,再查出所有content_id指向它的Entry——一条内容身份可对应多个物理位置; - 经用户元数据扇出:
"user_metadata"/"user_metadata_tag"依赖下,依据元数据的作用域(entry 级或 content 级)路由到单个 Entry 或该内容的所有 Entry。
批量组装由File::from_entry_uuids(core/src/domain/file.rs)完成,通过is_in批量加载 entries、content_identities、sidecars、tags,并借助HashMap归组避免 N+1 查询;同时为每个文件填充alternate_paths(含自身物理路径与其它同内容副本),使前端可以据此实现“服务器端过滤”与去重展示。
三、即时元数据能力:metadata_id与常驻UserMetadata
3.1 设计动机:文件被发现的瞬间即可组织
任务文档强调“EveryEntryis designed for immediate metadata capability via a linkedUserMetadatarecord”。领域层对这一点有更直白的注释(core/src/domain/user_metadata.rs):
This is the key innovation: EVERY Entry has UserMetadata, even if empty. This means any file can be organized immediately without content indexing.
即:即使某个文件尚未完成内容指纹/哈希(content_id为空),它也能立刻被收藏、加备注、隐藏或打标签,因为元数据能力不依赖内容识别结果。
3.2 领域结构UserMetadata
core/src/domain/user_metadata.rs 定义的UserMetadata包含:
| 字段 | 类型 | 说明 |
|---|---|---|
id | Uuid | 唯一标识,与Entry.metadata_id对应 |
notes | Option<String> | 自由文本备注 |
favorite | bool | 是否收藏 |
hidden | bool | 是否隐藏 |
custom_fields | JsonValue | 自定义扩展字段(未来扩展点) |
created_at/updated_at | DateTime<Utc> | 创建与更新时间 |
UserMetadata::new(id)生成全空元数据(notes: None、favorite: false、hidden: false、空对象custom_fields),并提供set_notes、toggle_favorite、set_hidden与is_empty()等操作,每个变更都会刷新updated_at。单元测试test_empty_metadata验证了空元数据的判定逻辑(core/src/domain/user_metadata.rs)。
3.3 数据库实体:作用域(Scope)设计
数据库层的user_metadata实体(core/src/infra/db/entities/user_metadata.rs)将作用域建模为二选一的可空外键:
pub entry_uuid: Option<Uuid>, // File-specific metadata (higher priority) pub content_identity_uuid: Option<Uuid>, // Content-universal metadata (lower priority)MetadataScope枚举(同文件 L68-L72)明确两种语义:
- Entry 级(
MetadataScope::Entry):只作用于某一个 Entry 实例(如“这个副本的备注”); - Content 级(
MetadataScope::Content):作用于拥有同一内容身份的所有 Entry(如“这张照片的收藏,无论在哪台设备上”)——File::from_entry_uuids的标签组装逻辑(core/src/domain/file.rs)会把 content 级标签自动应用到该内容的所有副本上。
创建与获取由UserMetadataManager(core/src/ops/metadata/manager.rs)提供:get_or_create_entry_metadata(entry_uuid)(L47-L79)与get_or_create_content_metadata(content_identity_uuid)(L82 起)。以 entry 级为例,其创建逻辑为:先按entry_uuid查询,不存在则插入一条notes=None, favorite=false, hidden=false, custom_data={}的空元数据记录,从而兑现“发现即可用”。
四、内容身份:content_id与跨设备去重
Entry的另一个关键外键是content_id,指向内容身份表,用于去重:多个物理 Entry(不同设备、不同路径)可以共享同一个内容身份。
领域模型ContentIdentity(core/src/domain/content_identity.rs)包含uuid、kind(ContentKind)、content_hash、integrity_hash、mime_type_id、text_content、total_size、entry_count、first_seen_at、last_verified_at。ContentKind是 27 个值的分类枚举(Unknown=0、Image=1、Video=2、Audio=3、Document=4、Archive=5……Memory=26,见 core/src/domain/content_identity.rs)。
哈希生成策略(ContentHashGenerator,同文件 L132-L264)值得一提,因为它直接决定去重的成本与准确性:
- 空文件直接拒绝生成内容身份(
ContentHashError::EmptyFile),避免所有空文件因相同哈希被误判为同一内容; - 小于等于
MINIMUM_FILE_SIZE(100KB,1024 * 100)的文件整读全哈希; - 大文件采用采样哈希:读头部 8KB(
HEADER_OR_FOOTER_SIZE)+ 均匀分布的 4 个采样块(SAMPLE_COUNT=4,每块 10KB,SAMPLE_SIZE)+ 尾部 8KB,总共只传输约 58KB 数据。该算法通过VolumeBackend::read_range实现,因此对云端文件同样只需范围读即可高效计算内容身份,无需整文件下载。
五、数据库 Schema:entries表与关联关系
5.1 建表语句
初始迁移 core/src/infra/db/migration/m20240101_000001_initial_schema.rs 中的entries表(SeaORM 使用Entries常量)完整字段如下:
| 列 | 类型 | 约束/说明 |
|---|---|---|
id | integer | 自增主键 |
uuid | uuid | 同步与 UI 缓存兼容用的稳定标识 |
name | string | 非空,条目名(含扩展名) |
kind | integer | 非空,0=File / 1=Directory / 2=Symlink |
extension | string | 可空,文件扩展名(不含点),目录为 NULL |
metadata_id | integer | 可空,外键 →user_metadata.id,ON DELETE SET NULL |
content_id | integer | 可空,外键 →content_identities.id,ON DELETE SET NULL |
size | big_integer | 非空,文件字节数 |
aggregate_size | big_integer | 非空,含全部子项的合计大小(目录) |
child_count | integer | 非空,直接子项数量 |
file_count | integer | 非空,目录及其子目录中的文件总数 |
created_at/modified_at | timestamp with tz | 非空 |
accessed_at | timestamp with tz | 可空 |
permissions | string | Unix 权限字符串 |
inode | big_integer | 平台相关文件标识,用于变更检测 |
parent_id | integer | 自引用,指向父目录的entries.id |
SeaORM 实体 core/src/infra/db/entities/entry.rs 与之对应,并额外携带indexed_at(索引/同步时间戳,同步水位线)与volume_id(所属卷,卷归属设备从而推导 Entry 的设备所有权)。实体定义的四个关系(同文件 L33-L55)为:UserMetadata(metadata_id → user_metadata.id)、ContentIdentity(content_id → content_identities.id)、Parent(自引用)、Volume(volume_id → volume.id)。metadata_id与content_id均使用ON DELETE SET NULL,保证元数据/内容身份被删除时 Entry 仍然存活。
5.2 层级查询:entry_closure闭包表
父子层级通过自引用parent_id表达,而高效的后代/祖先查询依赖闭包表entry_closure(ancestor_id, descendant_id, depth)。同步链路在插入 Entry 后调用rebuild_entry_closure(core/src/infra/db/entities/entry.rs):删除该 Entry 作为后代的旧闭包记录 → 插入 depth=0 的自引用 → 复制父节点全部祖先关系并depth+1。同步回填完成后则调用rebuild_all_entry_closures(L772-L842)做整体重建:清空闭包表 → 批量插入自引用 → 迭代执行INSERT OR IGNORE直至无新关系(超过 100 轮视为存在环,报错退出)。这保证了子树删除(delete_subtree)、位置作用域过滤等操作在同步数据上依然正确。
六、同步语义:设备所有权的状态复制
Entry在 core/src/infra/db/entities/entry.rs 注册为设备所有(device-owned)的同步模型:SYNC_MODEL = "entry",exclude_fields = ["id", "indexed_at"](主键与本地水位线不外发),sync_depends_on = ["volume", "content_identity", "user_metadata"],确保外键目标先于 Entry 到达。
关键设计(同文件apply_state_change,L476-L659)包括:
- 无 HLC 排序、幂等 upsert、last-write-wins:Entry 只有属主设备修改,因此采用基于状态的复制而非日志复制,按 UUID 幂等写入;
- UUID ↔ 本地整型主键映射:
foreign_key_mappings(L101-L110)声明volume_id→volumes、parent_id→entries、metadata_id→user_metadata、content_id→content_identities四组外键映射,同步广播前批量把整型 FK 转换为 UUID,接收端再映射回本地 ID(map_sync_json_to_local); - 墓碑保护:Entry 自身或父节点已被墓碑标记时跳过应用,防止孤儿节点与复活竞态(L511-L560);
- 目录路径一致化:同步时目录会附带
directory_path绝对路径并写入directory_paths表(L623-L656),使不同设备上的目录拥有完全一致的寻址路径,避免本地重建路径的偏差(例如根目录应保留/Users/jamespine/Downloads而非Downloads); - 删除级联:
apply_deletion按 UUID 找到 Entry 后调用delete_subtree级联删除整棵子树(L407-L420),且不产生额外墓碑。
同步查询端(query_for_sync,L197-L397)以indexed_at作为水位线,采用(indexed_at, uuid)双字段游标分页保证确定性;设备过滤通过JOIN volume ON volume.device_id = ?实现,使卷的所有权变更可自动迁移其下全部 Entry。
七、验收标准核验与工程启示
对照任务文档的三条验收标准,可在当前源码中逐一找到证据:
| 验收标准 | 源码证据 |
|---|---|
Entry结构体存在且包含metadata_id与content_id | entries表列定义(迁移脚本 L242-L243)与实体模型 core/src/infra/db/entities/entry.rs |
| 系统能使用统一模型表示文件和目录 | EntryKind::{File=0, Directory=1, Symlink=2}双端定义,File聚合模型统一承载 |
数据库 Schema 正确反映Entry模型 | 初始迁移entries表 +entry_closure表 +user_metadata/content_identities外键 |
从这套设计中可以提炼几条值得借鉴的建模原则:
- 统一载体 + 可空增强列:文件/目录/符号链接共用一个模型,
metadata_id、content_id均设计为可空外键,让“基础能力”与“增强能力”解耦——未哈希、未加元数据的条目也完整可用; - 计算型聚合视图:持久层保持扁平、可同步的
Entry,对外暴露的是按需批量组装的File聚合视图,兼顾同步简单性与业务表达力; - 作用域驱动的元数据:
entry_uuid与content_identity_uuid二选一的作用域设计,让“单副本备注”与“全内容收藏”两种语义共存且冲突规则清晰(entry 级优先); - 同步语义与数据所有权绑定:设备所有的状态复制省去冲突消解,闭包表随写入即时维护,墓碑与水位线(
indexed_at)保障删除与增量同步的正确性。
若要在实际工程中落地或扩展该模型,可直接以 core/src/domain/file.rs、core/src/infra/db/entities/entry.rs 与 core/src/infra/db/migration/m20240101_000001_initial_schema.rs 为参照:新增字段时需同步修改实体模型、领域聚合与同步字段排除清单,并确保entry_closure重建逻辑覆盖新增的父子关系;扩展元数据类型时,优先复用custom_fieldsJSON 字段而非新增硬编码列,以保持同步与迁移的最小化。
【免费下载链接】spacedriveSpacedrive is an open source cross-platform file explorer, powered by a virtual distributed filesystem written in Rust.项目地址: https://gitcode.com/gh_mirrors/sp/spacedrive
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考