DeepSeek Harness 的存储层:storage 包统一了什么、又没统一什么

2026-08-17

翻 DeepSeek Harness 的存储子系统,最容易踩空的一步是把「有统一的存储抽象」直接读成「换后端不用改任何东西」。仓库里 packages/storage/ 这一组确实把后端约定写成了规范性文本,也确实用一套共享用例卡两个后端;但配置字段、版本戳的层数、耐久的实现路径、抛出来的错误类型,这几处是各走各的。下面沿一条真实记录的写入路径走一遍,把统一线和分界线分别标在具体文件上。

先把前提说清楚:该仓库 README 自述处于开发者预览阶段,并明写会有破坏兼容性的变更。下面出现的配置键、默认值、错误码都随时可能变,别照着写进你自己的长期依赖。

起点:一条 workspace 记录写下去,落在哪个文件里

packages/workspace/workspace/src/spec.ts 里用 defineDomain 声明了一个域:name: 'workspace'version: 2、一张 workspaces 表,外加一个 global 单例 slot,初值是 { initialized: false, workspaceIds: [], archivedSessionIds: [] }。同样的写法在仓库里另有两处:message_feedback(version 0,一张 sessions 表,无 global)和 session_projcache(version 3,一张 sessions 表)。

调用方拿到的是 ctx.storageDomain.open(spec) 返回的句柄。这一步在 packages/storage/storage-domain/src/index.ts 里按固定顺序执行:先占住域名(重复打开抛 already-open),再查路由表拿后端名,要求这个后端有 kv facet(没有就抛 facet-unsupported),然后把 spec 投影成 unit 描述符交给后端打开,最后把介质上的每一条记录逐条过 spec 里的 zod schema——任何一条不过,整次 openinvalid-record 失败,错误上带着出错的表名与键。

注意路由在哪儿:它是域插件的配置,不是枢纽的全局选择。Config 只有两个键,backend 是必填的默认路由,routes 是按域名逐个覆盖。源码里对 backend 必填的说明是「不存在一个放之四海皆准的介质」。

写入那一侧在 packages/storage/storage-domain/src/domain.ts:每次 putdeleteupdateglobal.set 都排在该域唯一的一条写链上,先在后端落盘,再改内存,最后发 domain/changed。顺序反过来是要出事的——后端拒绝时内存原样不动,所以同步读到的东西不会跑到介质前面去。update 是这条链上的一次原子读-改-写,键不存在时拒绝 missing-key;删一个不存在的键 resolve 成 false,既不写也不发事件。

统一线:backend.ts 是约定原文,contract.ts 是判卷的

packages/storage/storage/src/backend.ts 全文 104 行,里面没有任何实现,只有接口和逐条款的注释:一个后端拥有一个介质,facet 是可选成员,服务不了某种数据形态就直接不实现、让解析响亮地失败,而不是给个半吊子实现。KvUnit 上只有 loadAllputRecorddeleteRecordsetGlobalclose 五个方法,值对这一层是不透明 JSON——没有 schema、没有事件、没有领域含义。

判卷的是 packages/storage/storage/tests/contract.ts 里的共享一致性套件。两个后端的 spec 文件各自 import { runKvBackendContract },用同一个描述符(name: 'contract_unit'version: 3、两张表 alphabeta、带 global)跑同一批用例,我们数下来是 5 条:空 unit 打开即可 loadAll、重开后记录与 global 仍在、putRecord 覆写且 deleteRecord 幂等、重开时版本不匹配要拒绝且不动数据、close 之后一切调用被拒且 close 幂等。想自己加第三个后端,这套用例就是入门考卷。

命名规则也是统一的,而且它划的边界值得记住。UNIT_NAME_RE/^[a-z][a-z0-9_]*$/,约束的是 unit 名和表名——因为它们既要当文件名,又要当 SQL 标识符片段。记录键则是任意字符串,源码里明写「键绝不进入文件路径」。sqlite 侧把两段名字拼成物理表名 u_<unit>_<table>,拼之前两段都过了这个正则,所以 DDL 里没有外部输入。

岔开之一:两个后端的配置形状不一样

json 后端(packages/storage/storage-json/src/index.ts)的 Config 只有一个 root,而且没有默认值,源码注释给的理由是:拿 process.cwd() 兜底会把 unit 文件散落到进程碰巧启动的地方,所以要求装配显式写明位置。目录按需创建,模式 0o700

sqlite 后端(packages/storage/storage-sqlite/src/index.ts)的 Config 有两个键:path 必填(特殊值 :memory: 开进程内库),journalMode 可选、默认 wal。可选值被收窄成 wal/delete/truncate/persist 四个,memoryoff 被明确排除,schema.ts 里的注释写的是这两种会「静默违反 KV 后端约定的耐久条款」。rollback-journal 那三种存在的理由,注释里说是给 WAL 共享内存文件不工作的文件系统(网络挂载)用的。

所以「换个后端」在配置层面不是改一个名字的事:两边连必填项都不同名。这是默认值层面的事实,不是对你实际跑起来会怎样的保证。

岔开之二:版本戳有两层,只有一层两边都有

unit 自己的 version 两边都有,落法不同:json 后端写在文件头里,format.tsserialize 产出的文档形状是 { unit: { name, version }, global, tables },pretty-print 两空格缩进带结尾换行;sqlite 后端写在 units 元数据表的一行里,首次打开时戳上。两边在版本对不上时都拒绝 version-mismatch

多出来的那一层只有 sqlite 有:物理布局版本存在 PRAGMA user_version,常量 STORAGE_SQLITE_SCHEMA_VERSION 当前是 1。它和 unit 自己的版本是正交的,只在表布局发生破坏性变更时才动,任何其它戳值一律拒绝。json 侧我们没有在源码里找到对应的物理布局版本概念。两边都不做迁移,注释里对此的措辞是「未发布格式,没有迁移」。

还有一处顺序细节值得看:sqlite 的 configureDatabasePRAGMA user_version 的戳放在建表之后,注释说明戳意味着布局已完整,所以前面任何一步失败都必须让介质保持未戳状态。

岔开之三:耐久是同一句话,实现路径不同

约定里的说法是每次单独调用在介质上都是原子的、resolve 后即已持久。两边怎么做到的差得很远。

json 后端的 atomic.ts 是这样发布的:在同目录写一个 .<uuid>.tmp 临时文件(wx 模式、0o600),fsync 它,然后 rename() 覆盖目标,POSIX 上再 fsync 一次父目录。每次写都是整文件重新发布——文件永远是当前净状态,源码里说可读性就是这个后端存在的理由。发布失败还要把内存回滚回去,unit.tsputRecord 里对此的注释是:内存是权威的,被拒绝的写不能留在内存里,也不能搭下一次发布的车。

Windows 侧要单独看两行。fsyncDirectory 里第一句就是 if (process.platform === 'win32') return——Windows 拒绝以只读方式打开目录,父目录 fsync 这一步在 Windows 上不执行。包 README 的「已知限制」里把这件事又写了一遍:Windows 的耐久性依赖 libuv 的 rename()(映射到带替换标志的 MoveFileExW),没有显式的 write-through 标志;更严格的 Win32 发布助手写的是计划在 append-log facet 落地时下沉过来。这是文档里白纸黑字标着的待办,不是已有能力。

sqlite 后端走的是另一条路:unit.ts 里每个原语都是一条预编译语句(INSERT ... ON CONFLICT DO UPDATEDELETESELECT),原子性来自 SQLite 单语句本身,不开显式事务,也没有写队列。连 close 的分量都不一样:json 的 unit close 要先 drain 在途的发布再释放,sqlite 的 unit close 只是同步置一个标志、把名字槽还给后端,然后 resolve。

岔开之四:错误词表是两套

StorageError 的码有 7 个:backend-not-foundform-not-mountedduplicate-backendduplicate-mountversion-mismatchmalformed-mediumclosedDomainError 的码有 5 个:already-openfacet-unsupportedinvalid-recordmissing-keyclosed。两边都有 closed,而且域层的注释明写后端失败原样透传、不重新包装。落到写 catch 的人身上就是:判分支时得同时认这两个类,别只 instanceof 一个。

同一条非法输入在两个后端抛出来的东西也不同:unit 名不匹配 UNIT_NAME_RE 时,json 后端的 validateDescriptor 抛的是 StorageError('malformed-medium', ...),sqlite 后端的 openUnit 返回的是一个普通 Error,消息里带上违反的正则。两处位置都在上面给了,差异就是这样,我们不去推断哪边是对的。

跨进程:三份 README 都把它划在了界外

这一条两个后端和域层是一致的——一致地不支持。json 后端的已知限制写明没有跨进程写锁,两个进程写同一个 root 会交错整文件替换、最后写的赢;sqlite 后端写明没有 busy 等待或重试策略,另一个连接持有写事务时操作会立刻被拒,也没有多进程写保护;域层 README 写明 domain/changed 只是进程内事件,第二个宿主进程或重连的 GUI 在跨进程修订方案落地之前观察不到变化。域层还列了另一条:没有跨表事务、没有二级索引、没有多段键,每次写只碰一条记录。

装配里实际挂的是哪一个

packages/bundle/web-app/cordis.patch.yml:插入的行里有 storagestorage-jsonconfig.root 填的是 !!js dshHomePath('storages'))、storage-domainconfig.backend 填的是 json,没有写 routes)。我们在 packages/bundle/ 下没有找到注册 sqlite 存储后端的行——那里出现的 session-query-sqlite 属于会话查询那一组,不是存储家族的后端。而 docs/subsystems/storage.md 对 sqlite 后端的定位写的是「在单个数据库中每行存储一份文档,用于频繁更新的数据」。两处摆在一起就是这样,我们只陈述,不替作者解释。

对读者的实际意义是:按当前装配,前面那三个域全都落在 json 后端上,域名同时就是 unit 名,也就是配置的 root 目录下的 workspace.jsonmessage_feedback.jsonsession_projcache.json 三个文件。想验证结构,打开其中一个看它的 unit.version 那一行就够了——workspace.json 里应该是 spec 里声明的 2

最后提一处 defineDomain 的守卫,写自己的域时会撞上:global 的 zod schema 如果接受 null,模块加载阶段就会抛。理由源码里写了——null 是介质上「从未写入」的哨兵值,可空的 global 存进去之后再读出来就分不清是存了个 null 还是压根没写过,会被静默换成 initial。同一个守卫还卡域名、表名和「版本必须是非负整数」,全都在拥有包的模块加载时、任何介质被碰之前报错。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。