12 KiB
description, kind
| description | kind |
|---|---|
| 面向插件作者与维护者的用户设置服务:注册可配置 namespace、读取解析值或接入配置界面。 | package-reference |
@deepseek-ai/dsh-settings
English | 中文
概述
dsh-settings 让插件把配置开放给用户运行时修改:插件用一个 schema 注册 namespace,解析值依次尊重 schema 默认值、部署自身的组合 base 与用户编辑的文档分节——用户覆盖优先。消费方读取解析值快照并在每次已提交变更后收到通知;配置界面每个 namespace 得到一条 descriptor——schema、当前值、每个字段来自哪一层、生效时机——而无需直接触碰存储。写入只改动用户覆盖、按 namespace 逐个执行,并可携带期望 revision,让持有陈旧快照的写入方被拒绝,而不是悄悄覆盖较新的写入。文档必须由挂载的提供方存储;没有提供方时一切照旧,配置保持组合原样。
目录
使用本包
插件与配置界面通过 ctx.settings 在运行时读取并修改配置。常用路径:挂载提供方、用 schema 注册 namespace、读取并观察解析值,并通过 owner scope 写入。
何时选择
当插件的配置需要在运行时可变——用户编辑文档或配置界面修改——且无需重启或重读 cordis.yml 时,选择设置服务。它适合多个插件各拥有一个配置 namespace、以及配置界面需要渲染 schema、标记用户覆盖字段并持久化编辑的场景。当配置在加载时固定则没有必要:没有挂载提供方时一切照旧,配置保持组合原样。
挂载提供方
服务本身不存储任何内容;请挂载一个提供方,例如随附的文件型提供方:
- name: '@deepseek-ai/dsh-settings-file'
config:
path: /absolute/path/to/settings.yaml
提供方上线后 ctx.settings 即出现。完整配置面由提供方 README 负责;生成的配置目录列出每个受支持字段。
注册 namespace
插件用 schemastery schema 注册自己的 namespace,并可选地把组合配置作为 base 层传入,让解析值从部署已配置的内容起步:
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: config, // composition entry config; the user layer resolves above it
})
const theme = scope.get() // deep-frozen resolved snapshot
scope.update({ density: 'compact' }) // merges into the user section and persists
installSettingsSection 为消费方插件封装了这套接线:只要设置服务存在,它就用插件的组合配置作为 base 注册 namespace;服务消失时插件回退到组合配置,行为与原先完全一致。
读取与观察值
get(ns) 以深冻结快照返回解析值,namespace 未注册时为 undefined。watch(callback) 在每次已提交变更后以 (next, prev) 调用回调:同一回调的调用按提交顺序逐个执行,异常被隔离并记入日志,因此慢或抛错的观察者绝不会阻塞或破坏其他观察者。
写入值
update(ns, patch) 把普通对象 patch 深合并进用户分节——绝不进 base——校验解析候选值、经提供方持久化后提交。replace(ns, section) 整体替换用户分节,是删除/重置路径:replace({}) 重新继承 base 与 schema 默认值。mutate(ns, ops) 在写入排到队首那一刻的分节上按序施加 { op: 'set' | 'unset', path } 编辑——这是持有不完整(例如脱敏后)视图的调用方的删除路径,因为按协议接口返回的内容重建分节再整体替换,会删掉协议从未回传的每个字段。
每次写入都会拒绝与 JSON 不兼容的数据(Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前以 $ 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 expectedRevision:把 descriptor 中的 revision 传回,namespace 已越过该值时写入会被 SettingsConflictError 拒绝,而不是覆盖先完成写入的一方。
配置界面
describe() 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 base 与 user 层(字段出现在 user 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 redactSecrets: true:它从每一层剥离 role('secret') 字段,并把它们枚举为 { path, set } slot,让页面可以渲染只写输入而不接触任何机密。documentPath 与 prepareDocument() 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。
事件与失败
settings/updated (ns, next, prev, source) 在每次已提交变更后触发——进程内写入(source: 'update')或外部观察到的编辑(source: 'provider')——解析值深相等时绝不触发。settings/document-updated (ns, revision) 在原始用户分节发生变化时触发,即使解析值没有变——已打开的编辑器正需要它来得知字段从继承变为覆盖。schema 拒绝的存量分节在重载时保留该 namespace 的最后可用值并告警;注册时同样的失败会直接拒绝注册。
理解实现
实现细节——点击展开
本节解释服务背后的设计决策并指出实现它们的代码位置;可观察行为已在使用本包中完整说明。
设计理念
- 分层解析,单一用户层。 namespace 的值依次为 schema 默认值、注册方的组合
base、用户文档分节;写入只触碰用户层,因此replace({})是真正的重置。 - 提交以深相等为门槛。 只有解析值变化时
settings/updated才触发;原始分节事件独立存在,因为配置界面还必须得知「继承变成了覆盖」。 - 写入排队并做 revision 检查。 每个 namespace 的写队列按调用顺序串行,
expectedRevision在队首判断——那里服务才能分辨持有新鲜快照的写入方与持有陈旧快照的写入方。 - 观察者与监听器异常被隔离。 watcher 调用与事件扇出隔离同步抛出与异步拒绝,一个坏掉的观察者不会卡死提交或提供方的重载循环;
INVARIANT编码的失败在所有监听器执行完后重新抛出。 - 注册是 fiber 上的 effect。 注册 namespace 是调用方插件 fiber 上的 effect:dispose(资源释放)该 fiber 即移除 namespace 及其观察者。
源码地图
| 文件 | 职责 |
|---|---|
src/index.ts |
Service Definition:namespace brand、注册、解析、写队列、describe/脱敏、事件、installSettingsSection |
src/redact.ts |
redactSecrets 遍历器:剥离 role('secret') 字段并枚举其 slot |
src/types.ts |
客户端安全类型面:事件声明、SettingsNamespace、SettingsUpdateSource |
src/invariant.ts |
不变式伴生插件:settings/updated 只对已注册 namespace、只在解析值变化时、且携带权威值触发 |
解析与写入路径
每次写入都在调用时对输入做快照(分离并校验 JSON 形状的数据),然后排上该 namespace 的串行链。在队首,服务按当前状态重读分节、检查 expectedRevision、合并/替换/编辑、经 schema 与 owner 的可选 validate 解析并校验候选值、经提供方持久化,然后才提交并发出事件。registrant fiber 在写入途中被 dispose 的写入仍到达存储,但不会提交、也不会通知任何人;卸载先拒绝新写入,并排干排队写入与已启动的 watcher 调用后才完成。
变更检测与事件
commit 用 seam 的 deepEqualJson 谓词比较解析值,并逐监听器扇出 settings/updated。bumpRevision 比较原始分节并携带新 revision 发出 settings/document-updated;它与解析值检查相互独立。两个扇出以相同方式隔离监听器异常。
客户端安全类型
./types 子路径出口持有事件声明及其签名点名的 SettingsNamespace、SettingsUpdateSource 类型,包根继续 re-export 这些类型。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
进一步探索
当服务级约定不够用时阅读以下页面。它们从共享子系统词汇逐步进入随附提供方与能力架构。
- 设置子系统参考——namespace、注册、owner scope、descriptor、变更提交与生成的 cordis 接口面。
- 文件型设置提供方——随附的 YAML/JSON 提供方:配置、热重载、保留注释的写入。
- 设置包映射——用户设置能力的两个包及其角色。
- 能力 seam——本服务遵循的 Service Definition / Service Provider / Consumer 拆分。
模型体验
间接生效:消费方插件拥有任何由设置值喂给的模型面内容;本服务只存储并解析用户设置,自身不注册任何模型面内容。
KV Cache 影响
无直接失效;把设置值纳入请求前缀的消费方负责该变更。
已知限制与延期工作
这些限制说明本服务何时不合适或需要特别注意。它们是当前包约束,不是任务积压。
- 单一用户层——解析只认识 schema 默认值、一个组合
base与一个用户文档;它不记录每个解析值由哪一层提供。 redactSecrets并非一条可被证明的协议边界——遍历器只跟随object/dict/array容器,因此只能经由 union、intersection 或 transform 抵达的role('secret')字段会被原样返回,且secrets列表为空;序列化 schema 还会把 secret 字段的默认值带给每个客户端。两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。fail-closed 的describeForWire()——拒绝自己无法证明安全的 schema,并对序列化封装与错误文本做净化——是暂缓的答案。- 跨进程并发由提供方定义——服务仅在进程内按 namespace 串行写入;跨进程并发按提供方行为收敛(文件提供方在写锁下读-改-写,因此并发写入者不会丢掉彼此的 namespace,同 namespace 冲突按后写胜出解决)。
开发备注
维护者的工作上下文——点击展开
本开发备注是维护者的工作上下文:尚未决定的开放设计方向。它明确非权威——已发布的行为、限制与已接受的理由见上文各节与包代码。代码 TODO 中记录的开放方向:把公开的 ns 参数更名为 namespace(API、提供方约定、实现、测试与消费方同步);注册释放时停用所有 watcher 并等待其 tail,让回调不越过 registrant fiber 存活;替换注册从持久化分节重新解析,让进行中的旧写入不会把它留成陈旧值;改用属性安全的对象构造,让 __proto__ 这类合法 JSON 键保持为自有数据。fail-closed 的 describeForWire() 净化器是上文脱敏限制的暂缓答案。