跳到主要内容
路径文档

存储层

一句话版ctx.storage 是 DSH 存非会话数据的地方:一个 named 后端注册表 + 挂载的数据形态设施,后端管媒体、数据形态管语义,kv 是当前唯一数据形状(JSON / SQLite 后端)。

会话历史在 会话系统(事件溯源)。那"其他数据"呢(插件状态、键值、配置之外的持久数据)?存这就对了。

一、Hub 的想法

ctx.storagehub,不做任何 IO:

  • 多个后端并列:jsonsqlite 一起挂着,由消费方配置决定用哪个(domain 层的路由表),不是 hub 全局选一个
  • register() 返回 disposer;重复名 / 未知查找 fail loud
  • 数据形态 StorageForms 可声明合并;domain 层 merge 后以 ctx.storage.domain 访问

二、JSON 后端(可读性优先)

dsh-storage-json 注册为后端 json:一个 unit 一个 <unit>.json 文件(在配置的 root 下)。

  • 写:内存 unit 状态为权威,每次写用 temp-write + fsync + 原子 rename() 整个重发布文件;unit 文件始终是完整净态(可读性是这个后端存在的理由;要规模用 sqlite)
  • 缺失文件 = 空 unit,首次写时实体化;外来/不可解析文件拒绝 malformed-medium;版本不符拒绝 version-mismatch
  • 每次单调用原子、持久;跨调用的写顺序归调用方(domain 层写链)
# 配置
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: $DSH_HOME/storages # 必填无默认,按需 0o700 创建

三、SQLite 后端(规模)

dsh-storage-sqlite 注册为后端 sqlite,在一个 node:sqlite 数据库文件(或 :memory:)上提供 kv facet。要规模、多 unit 并发,用它替 json。

存储模型是一行一条记录:每个 unit 表物化为 "u_<unit>_<table>" 的 STRICT 表(key TEXT PRIMARY KEY, value TEXT),value 是记录的 JSON 文本,所以一次 key 更新只动一行(这正是把高写频率域路由到这里而非 json 的原因)。unit 身份放在两张元数据表:units 首次 open 时给每个 unit 盖格式版本(不符拒绝 version-mismatch),unit_globals 存每个 unit 的全局单例行;物理布局版本在 PRAGMA user_version。unit/表名先过 hub 的 UNIT_NAME_RE 校验才进 DDL,外部输入绝不拼接进 SQL 标识符

每次写原语是单条 prepared statement——SQLite 的 per-statement 原子性满足 KV 契约,无需显式事务;跨调用的写顺序仍归调用方(domain 写链)。缺失目录与库文件按 owner-only 创建(0o700 / 0o600)。

# 配置
- id: storage-sqlite
name: '@deepseek-ai/dsh-storage-sqlite'
config:
path: $DSH_HOME/storage.sqlite # 库文件路径,或 ':memory:' 走进程内库
journalMode: wal # journal_mode pragma;默认 'wal'
字段默认说明
path必填SQLite 库文件路径,或 :memory:(进程内)
journalModewaljournal_mode pragma:wal / delete / truncate / persist

四、domain 层:类型化的 KV 域

domain 形态是"schema-validated、事件发射的 KV 域":插件挂自己的域,ctx.storage.domain 得到类型化 KV 接口。这避免把"随便塞个值"做成无约束状态。

dsh-storage-domain 提供可注入的 ctx.storageDomain 服务,并在所有后端注册后暴露匹配的 ctx.storage.domain 投影。一个域:

  • 声明一次:defineDomainzod record schema 定义记录,类型由 z.infer 派生;通过 DomainFacility.open 打开
  • 内存为权威:读是同步的,直接从进程内状态返回
  • 写走链:每次写先到达路由后端的持久层,再更新内存、发射 domain/changed 事件;每个域的写串行化在一条 per-domain 链上
  • 生命周期归消费方:Domain.close() 幂等释放句柄(通常是自己的 ctx.effect disposer);插件卸载时 facility 会关掉仍开着的域
# domain 后端路由
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json # 所有域的默认后端(必填:不存在放之四海皆准的介质)
routes:
workspace: sqlite # 按域覆盖:workspace 走 sqlite
字段说明
backend每个域的默认后端名(必填)
routes按域覆盖:域名 → 后端名

domain/changed进程内事件:它只在有 consumer 把它渲染进自己的 surface 时才被模型看到,本包自己不注册工具、不注入提示、不追加会话事件。

五、JSON vs SQLite:怎么选

jsonsqlite
可读性一个 unit 一个完整净态文件,可 cat/jq库文件,需 SQL 查询
写成本每次写重发布整个文件(temp-write + fsync + rename)一次 key 更新只写一行
并发无跨进程写锁(两进程同 root 写会整文件覆盖,last-write-wins)同步写,无 busy-wait/重试;另一连接持写事务立即拒绝
适用小量、低频、要人工检查的状态高写频率域、多 unit 并发

经验法则:

  • 要规模、高频更新 → 路由到 sqlite(一行一写的写成本是它的存在理由)
  • 要人工可读、便于调试 → 用 json(文件始终是完整净态,是它的存在理由)
  • 两者可并列挂着,由 domain 的 routes 按域分流,不是全局二选一

六、组合配置示例

json 与 sqlite 一起挂,高频域走 sqlite、其余走 json:

# ~/.dsh/profiles/web/cordis.patch.yml
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: $DSH_HOME/storages # 必填无默认,按需 0o700 创建

- id: storage-sqlite
name: '@deepseek-ai/dsh-storage-sqlite'
config:
path: $DSH_HOME/storage.sqlite
journalMode: wal

- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
routes:
workspace: sqlite

七、对模型的可见性

ctx.storage 不注册工具、不注入提示、不写会话事件:纯 host 侧注册表。

  • Token 效应:每次请求零直接 token
  • KV cache 效应:不碰请求前缀,不影响 provider 缓存复用

八、已知限制

  • kv 是唯一数据形状(后端目前只需实现一个 facet)
  • 数据形态 lazy 解析:在 domain 插件挂载前读 ctx.storage.domain 会抛 form-not-mounted;组装按序,错误配置 fail loud 而非静默
  • domain/changed进程内事件:第二个进程或重连的 GUI 在跨进程 revision 落地前看不到变化
  • 无跨表事务、二级索引、多段 key:每次写只碰一条记录
  • json 后端无跨进程写锁(两进程同 root 会整文件覆盖);sqlite 同步写阻塞事件循环(单 statement 时长)

九、验证

# 看 storage 后端是否装载(json/sqlite)
dsh web --dump-config | grep -iE "storage"
# 看 storage 目录(0o700)
ls -la ~/.dsh/storages/

下一步