存储层
一句话版:
ctx.storage是 DSH 存非会话数据的地方:一个 named 后端注册表 + 挂载的数据形态设施,后端管媒体、数据形态管语义,kv是当前唯一数据形状(JSON / SQLite 后端)。
会话历史在 会话系统(事件溯源)。那"其他数据"呢(插件状态、键值、配置之外的持久数据)?存这就对了。
一、Hub 的想法
ctx.storage 是hub,不做任何 IO:
- 多个后端并列:
json、sqlite一起挂着,由消费方配置决定用哪个(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:(进程内) |
journalMode | wal | journal_mode pragma:wal / delete / truncate / persist |
四、domain 层:类型化的 KV 域
domain 形态是"schema-validated、事件发射的 KV 域":插件挂自己的域,ctx.storage.domain 得到类型化 KV 接口。这避免把"随便塞个值"做成无约束状态。
dsh-storage-domain 提供可注入的 ctx.storageDomain 服务,并在所有后端注册后暴露匹配的 ctx.storage.domain 投影。一个域:
- 声明一次:
defineDomain用 zod record schema 定义记录,类型由z.infer派生;通过DomainFacility.open打开 - 内存为权威:读是同步的,直接从进程内状态返回
- 写走链:每次写先到达路由后端的持久层,再更新内存、发射
domain/changed事件;每个域的写串行化在一条 per-domain 链上 - 生命周期归消费方:
Domain.close()幂等释放句柄(通常是自己的ctx.effectdisposer);插件卸载时 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:怎么选
| json | sqlite | |
|---|---|---|
| 可读性 | 一个 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/