feat(settings): add user-settings seam (ctx.settings) + file provider

Two-package capability family mirroring session-persistence/:

- dsh-settings: abstract Settings service — namespace registry with
  caller-fiber effect registrations, layered resolution (schema defaults
  < composition base < user document), schemastery validation,
  per-namespace deep-equal commit detection, and the settings/updated
  event. Boot/registration validation fails loud; provider publishes
  keep last-good per namespace.
- dsh-settings-local: settings.yaml/.json provider — resolveSpec
  defaulting to $DSH_HOME/settings.yaml, chokidar hot reload,
  content-equality self-write suppression, atomic 0600 tmp+rename
  writes, comment-preserving YAML namespace patching.

Consumers register inside ctx.inject(['settings'], …), so every
composition works unchanged without a mounted provider. Real Loader +
Include composition test proves cordis.yml boot and external-edit hot
propagation; HMR disposal test proves registry cleanup. Both packages
hold per-file 100% coverage.

Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md):
one structural line per file for the new package group.

Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
This commit is contained in:
Yichen Jiang
2026-07-28 17:30:12 +08:00
parent f63d2deecf
commit ec0786e099
43 changed files with 2098 additions and 4 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/README.md
README.md: 7a91355dd01805938944f0abce77765021288e6d
README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0

View File

@@ -0,0 +1,12 @@
# settings/ — user-settings capability family
English | [中文](README.zh.md)
The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` |
| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) |
The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document.

View File

@@ -0,0 +1,12 @@
# settings/ — 用户设置能力族
[English](README.md) | 中文
用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
| 包 | 角色 | ctx key |
|---|---|---|
| `settings/` | 设置 seamnamespace 注册表、分层解析、提交事件 | `ctx.settings` |
| `settings-local/` | 文件 provider`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings` |
接口位于 `settings/settings/`provider 平级并列。网络配置中心 provider例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
README.md: 90428f98055d8e49fa1ec54e571453db2e0a5054
README.zh.md: 3532d6cee99cb46f54e23889ef1bb54b2548ecfa

View File

@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-settings-local
English | [中文](README.zh.md)
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded.
## Config
| Field | Meaning | Default |
|---|---|---|
| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
| `watch` | Watch the document and hot-publish external edits | `true` |
| `debounceMs` | Watcher write-settle window in milliseconds | `100` |
Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
## Behavior
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
- **Write-back is atomic and owner-only.** `persist` writes `<path>.tmp` with mode `0600` and renames over the target. YAML writes patch one namespace in the comment-preserving document; JSON re-serializes.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
## Model Experience
Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
#### KV Cache effect
No direct invalidation; the consuming plugin owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up.
- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting.
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.

View File

@@ -0,0 +1,36 @@
# @deepseek-ai/dsh-settings-local
[English](README.md) | 中文
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。
## 配置
| 字段 | 含义 | 默认 |
|---|---|---|
| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json` | harness home 下的 `settings.yaml` |
| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME``~/.dsh` |
| `watch` | 监听文档并热发布外部编辑 | `true` |
| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
## 行为
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
- **写回原子且仅属主可读。** `persist``0600` 权限写 `<path>.tmp` 后 rename 覆盖目标。YAML 写回在保留注释的文档里只修补目标 namespaceJSON 重新序列化。
- **按内容抑制自写。** provider 缓存最后可用文本watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
## Model Experience
间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。
#### KV Cache effect
无直接失效;请求前缀的变更由消费插件拥有。
## Known Limitations and Deferred Work
- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web靠原子替换加 watcher 重载收敛后写胜出lockfile 等真实冲突出现再做。
- **注释保留仅限 YAML** — JSON 文档重新序列化无注释JSON 本身没有)且丢失手工排版。
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-settings-local",
"description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^4.0.3",
"schemastery": "^3.18.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,226 @@
/**
* File-backed settings provider. One YAML or JSON document under the user's
* harness home carries every namespace section; external edits hot-publish
* through the seam and `update()` writes back preserving the user's comments.
* @module @deepseek-ai/dsh-settings-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Settings document path; defaults to `settings.yaml` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Watch the document and hot-publish external edits; defaults to true. */
watch?: boolean
/** Watcher write-settle window in milliseconds; defaults to 100. */
debounceMs?: number
}
/** Document format derived from the configured file extension. */
type SettingsFormat = 'yaml' | 'json'
const FORMATS: Record<string, SettingsFormat> = {
'.yaml': 'yaml',
'.yml': 'yaml',
'.json': 'json',
}
/** Fully resolved provider parameters; defaulting happens here, never inline. */
interface ResolvedSpec {
filename: string
format: SettingsFormat
watch: boolean
debounceMs: number
}
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/settings.yaml`.
* @param config - raw plugin config.
* @returns the resolved file location, format, and watch behavior.
*/
export function resolveSpec(config: Config): ResolvedSpec {
const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
const format = FORMATS[extname(filename)]
if (format === undefined) {
throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
}
return {
filename,
format,
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
path: z.string(),
dshHome: z.string(),
watch: z.boolean().default(true),
debounceMs: z.number().min(0).default(100),
})
private readonly spec: ResolvedSpec
/**
* Raw text of the last successfully parsed or persisted document;
* `undefined` while the file is absent. Watcher events whose content equals
* this cache are no-ops, which is also the self-write suppression.
*/
private text: string | undefined
/** Serializes watcher-triggered reloads so reads never interleave. */
private refreshTask: Promise<void> = Promise.resolve()
constructor(ctx: Context, public config: Config) {
super(ctx)
// Programmatic construction may bypass Schemastery normalization; resolve
// the same defaults in one explicit step either way.
this.spec = resolveSpec(config)
}
/** The local document is always writable through {@link Settings.update}. */
get writable(): boolean {
return true
}
protected async load(): Promise<Record<string, unknown>> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
this.text = undefined
return {}
}
const doc = this.parse(text)
this.text = text
return doc
}
protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
await mkdir(dirname(this.spec.filename), { recursive: true })
const temp = `${this.spec.filename}.tmp`
// Owner-only permissions apply to the temp file and survive the rename, so
// a document that may carry personal values is never world-readable.
await writeFile(temp, output, { mode: 0o600 })
await rename(temp, this.spec.filename)
this.text = output
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
// A parse failure here is a boot failure: an existing-but-invalid document
// must fail loud, never be silently ignored or overwritten.
this.publish(await this.load())
if (!this.spec.watch) return
const watcher = chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
watcher.on('all', () => {
this.refreshTask = this.refreshTask.then(() => this.refresh())
})
watcher.on('error', (error) => {
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
yield () => watcher.close()
}
/** Parse one document text into raw sections, failing on a non-map root. */
private parse(text: string): Record<string, unknown> {
let root: unknown
if (this.spec.format === 'yaml') {
const document = parseDocument(text, { prettyErrors: true })
if (document.errors.length > 0) {
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
document.errors.map(error => error.message).join('; ')}`)
}
root = document.toJS() ?? {}
} else {
root = text.trim().length === 0 ? {} : JSON.parse(text)
}
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`)
}
return root as Record<string, unknown>
}
/**
* Re-read the document after a watcher event. Unchanged content (including
* this provider's own writes) is a no-op; an unreadable or unparsable
* document keeps the last good sections and warns — a live hot-reload must
* never take the process down.
*/
private async refresh(): Promise<void> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) {
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
return
}
if (this.text === undefined) return
this.text = undefined
this.publish({})
return
}
if (text === this.text) return
let doc: Record<string, unknown>
try {
doc = this.parse(text)
} catch (error) {
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
return
}
this.text = text
this.publish(doc)
}
/** Render the next YAML text by patching one namespace in the comment-preserving document. */
private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
if (this.text === undefined) {
return new Document({ [ns]: section }).toString()
}
// this.text only ever caches content that parsed successfully, so this
// re-parse (for the mutable comment-preserving tree) cannot fail.
const document = parseDocument(this.text)
document.set(ns, section)
return document.toString()
}
/** Render the next JSON text by replacing one namespace key. */
private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
const root = this.text === undefined
? {}
: this.parse(this.text)
root[ns] = section
return `${JSON.stringify(root, null, 2)}\n`
}
}
export default SettingsLocal

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`.
* @module @deepseek-ai/dsh-settings-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local'
/** Cordis companion plugin name. */
export const name = 'settings-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this provider's contracts are file round-trip,
* watcher timing, and atomic-write behavior — IO effects proven by package
* tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,112 @@
/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, and an
* external edit of settings.yaml hot-publishes into the consumer's scope.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
}
async function loadComposition(): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [] }
const consumer = {
name: 'settings-consumer',
inject: ['settings'],
apply: (ctx: Context) => {
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
state.scope = scope
scope.watch(next => state.seen.push(next))
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: consumer',
' name: test-settings-consumer',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['test-settings-consumer', consumer],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, state, settingsPath }
}
describe('settings-local real composition', () => {
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
})

View File

@@ -0,0 +1,254 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal, resolveSpec } from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('resolveSpec', () => {
it('defaults watch and debounce when construction bypasses schema normalization', () => {
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
expect(spec.watch).toBe(true)
expect(spec.debounceMs).toBe(100)
})
})
describe('boot and reads', () => {
it('resolves defaults over an absent file and reports writable', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(ctx.settings.writable).toBe(true)
})
it('reads sections from an existing yaml document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
it('reads sections from a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
expect(written).toContain('theme: light')
})
it('reads an empty yaml document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('reads an empty json document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('fails loud at boot when the document exists but is unreadable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
})
it('fails loud on an unsupported extension', async () => {
const dir = await tempDir()
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
.rejects.toThrow(/not supported/)
})
it('fails loud at boot on unparsable yaml', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme: [unclosed\n')
await expect(boot({ path, watch: false })).rejects.toThrow()
})
it('fails loud at boot when the root is not a map of sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '- just\n- a list\n')
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
})
})
describe('persist', () => {
it('writes the merged section, creating the file with owner-only permissions', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('theme: light')
expect((await stat(path)).mode & 0o777).toBe(0o600)
// Atomic replace leaves no temp artifact behind.
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
it('preserves comments and unregistered sections across updates', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'# personal settings',
'ui-theme:',
' theme: light',
'# owned by a plugin that is not loaded right now',
'future-plugin:',
' keep: me',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ fontSize: 18 })
const written = await readFile(path, 'utf8')
expect(written).toContain('# personal settings')
expect(written).toContain('# owned by a plugin that is not loaded right now')
expect(written).toContain('keep: me')
expect(written).toContain('fontSize: 18')
expect(written).toContain('theme: light')
})
it('creates a json document from scratch', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('round-trips a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
})
})
describe('watch', () => {
it('publishes an external edit to registered scopes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get().theme).toBe('light')
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
})
it('keeps the last good document over an invalid edit, then recovers', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await writeFile(path, 'ui-theme: [unclosed\n')
// The bad edit must never take the live tree down or reset the value.
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
await writeFile(path, 'ui-theme:\n theme: dark\n')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('dark')
}, { timeout: 5000 })
})
it('treats file removal as an empty document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await rm(path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
}, { timeout: 5000 })
})
it('does not republish its own persisted write', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 10 })
const events: unknown[] = []
ctx.on('settings/updated', (ns, _next, _prev, source) => {
events.push({ ns, source })
})
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 300))
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
})
})

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const ThemeSchema: z<{ theme: string }> = z.object({
theme: z.string().default('dark'),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(scope.get()).toEqual({ theme: 'dark' })
await writeFile(path, 'ui-theme:\n theme: light\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'light' })
})
})
it('keeps the last good document when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'light' })
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'dark' })
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/paths"
},
{
"path": "../settings"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
README.md: e57db00c095a87f9f0b51397e030dec364f48e62
README.zh.md: 8733823106f0ef3880cbe2c567de2edcdeff2c86

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-settings
English | [中文](README.zh.md)
Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
## Service API
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
- `get(ns)` — resolved value, `undefined` while unregistered.
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every update.
- Resolved values are deep-frozen snapshots; watchers receive `(next, prev)` after each commit, and watcher failures are contained.
## Provider contract
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
## Events
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
## Model Experience
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
#### KV Cache effect
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
## Known Limitations and Deferred Work
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
- **Cross-process concurrency is provider-defined** — the seam serializes nothing across processes; concurrent writers converge by provider behavior (the local file provider is last-write-wins).
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-settings
[English](README.md) | 中文
抽象用户设置 seam`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
## 服务 API
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope``get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effectdispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
- `get(ns)` — 解析值;未注册时为 `undefined`
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider`writable: false`)拒绝一切更新。
- 解析值是深冻结快照;每次提交后观察者收到 `(next, prev)`,观察者异常被隔离。
## Provider 契约
子类实现 `writable``load()``persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
## 事件
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source``update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
## Model Experience
间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。
#### KV Cache effect
无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。
## Known Limitations and Deferred Work
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
- **跨进程并发由 provider 定义** — seam 不做跨进程串行化;并发写入者按 provider 行为收敛(本地文件 provider 为后写胜出)。
- **无 secret 字段脱敏** — `describe()` 原样返回解析值wire 面RPC/UI在暴露前必须对 `role('secret')` 字段脱敏。

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-settings",
"description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
}
}

View File

@@ -0,0 +1,304 @@
/**
* User-settings seam (`ctx.settings`). Providers store one raw document of
* per-namespace sections; plugins register a namespace schema and read the
* resolved value, which layers schema defaults, the registrant's composition
* `base`, and the user document section, in that order.
* @module @deepseek-ai/dsh-settings
*/
import { Context, Service } from 'cordis'
import { deepEqual } from 'cosmokit'
import type z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Nominal id of one registered settings namespace. */
export type SettingsNamespace = Branded<'SettingsNamespace'>
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
/**
* Brand a raw string as a {@link SettingsNamespace}.
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
* @returns the branded namespace.
*/
export function settingsNamespace(value: string): SettingsNamespace {
if (!NAMESPACE_PATTERN.test(value)) {
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
}
return value as SettingsNamespace
}
/** When a namespace's changes take effect for its owner. */
export type SettingsApplies = 'live' | 'restart'
/** Origin of one committed settings change. */
export type SettingsUpdateSource = 'update' | 'provider'
/** Registration options beyond the namespace schema. */
export interface SettingsRegisterOptions<T> {
/** Composition-layer values resolved below the user layer (entry-config subset). */
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
}
/** One registered namespace as surfaced to configuration UIs. */
export interface SettingsDescriptor {
/** The registered namespace. */
ns: SettingsNamespace
/** Serialized schemastery schema (`schema.toJSON()`). */
schema: unknown
/** Current resolved value. */
value: unknown
/** Owner's declared effect timing. */
applies: SettingsApplies
}
/** Owner-facing handle for one registered namespace. */
export interface SettingsScope<T> {
/** Current resolved value: schema defaults, then `base`, then the user layer. */
get(): T
/**
* Observe committed changes to this namespace's resolved value.
* @param callback - invoked after each commit with the next and previous values.
* @returns the disposer removing this observer.
*/
watch(callback: (next: T, prev: T) => void): () => void
/**
* Merge a partial patch into this namespace's user layer and persist it.
* @param patch - plain-object patch over the user section.
*/
update(patch: object): Promise<void>
}
declare module 'cordis' {
interface Context {
settings: Settings
}
interface Events {
/**
* Committed change to one registered namespace's resolved value. Emitted
* after the provider persisted (for `update`) or published (`provider`)
* the change; never emitted when the resolved value is deep-equal.
* @param ns - the namespace whose resolved value changed.
* @param next - the new resolved value.
* @param prev - the previous resolved value.
* @param source - whether the change entered through `update()` or the provider.
* @mode emit
*/
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
}
}
/** Whether a value is a plain data object (not an array, null, or class instance). */
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* Layer `over` onto `under`: plain objects merge recursively, every other
* value (arrays included) replaces the lower layer wholesale, and `undefined`
* entries in `over` are ignored so a sparse patch cannot erase lower keys.
*/
function mergeLayers(under: unknown, over: unknown): unknown {
if (over === undefined) return under
if (!isPlainObject(under) || !isPlainObject(over)) return over
const merged: Record<string, unknown> = { ...under }
for (const [key, value] of Object.entries(over)) {
if (value === undefined) continue
merged[key] = key in merged ? mergeLayers(merged[key], value) : value
}
return merged
}
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
function deepFreeze<T>(value: T): T {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
for (const entry of Object.values(value)) deepFreeze(entry)
return Object.freeze(value)
}
/** One live namespace registration owned by a registrant fiber. */
interface SettingsRegistration {
ns: SettingsNamespace
schema: z<unknown>
base: unknown
applies: SettingsApplies
resolved: unknown
watchers: Set<(next: never, prev: never) => void>
}
/**
* Abstract settings service. Providers implement raw-document storage
* (`load`/`persist`) and push external changes through {@link Settings.publish};
* the base class owns namespace registration, resolution, validation, change
* detection, and the `settings/updated` commit event.
*/
export abstract class Settings extends Service {
private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
/** Latest published raw document; empty until the provider's first publish. */
private document: Record<string, unknown> = {}
constructor(ctx: Context) {
super(ctx, 'settings')
}
/** Whether {@link update} may persist through this provider. */
abstract readonly writable: boolean
/**
* Read the provider's current raw document (namespace to raw section).
* @returns the detached raw document.
*/
protected abstract load(): Promise<Record<string, unknown>>
/**
* Durably store one namespace's merged user section.
* @param ns - the namespace being written.
* @param section - the complete merged user section to store.
*/
protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>
/**
* Register a namespace schema and receive its owner scope. The registration
* is an effect on the calling plugin's fiber: disposing that fiber removes
* the namespace and its observers. An invalid stored section fails the
* registration itself — the earliest point where the schema can judge it.
* @param ns - unique namespace; duplicate registration fails loud.
* @param schema - schemastery schema resolving this namespace's value.
* @param options - composition `base` layer and effect timing.
* @returns the owner scope for reads, observation, and updates.
*/
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
if (this.registrations.has(ns)) {
throw new Error(`settings namespace "${ns}" is already registered`)
}
const registration: SettingsRegistration = {
ns,
schema: schema as z<unknown>,
base: options?.base,
applies: options?.applies ?? 'live',
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
watchers: new Set(),
}
this.ctx.effect(() => {
this.registrations.set(ns, registration)
return () => this.registrations.delete(ns)
}, `settings.register(${JSON.stringify(String(ns))})`)
return {
get: () => registration.resolved as T,
watch: (callback) => {
registration.watchers.add(callback)
return () => registration.watchers.delete(callback)
},
update: patch => this.update(ns, patch),
}
}
/**
* Describe every registered namespace for configuration surfaces.
* @returns one descriptor per registered namespace, in registration order.
*/
describe(): SettingsDescriptor[] {
return [...this.registrations.values()].map(registration => ({
ns: registration.ns,
schema: registration.schema.toJSON(),
value: registration.resolved,
applies: registration.applies,
}))
}
/**
* Read one registered namespace's resolved value.
* @param ns - the namespace to read.
* @returns the resolved value, or `undefined` while unregistered.
*/
get(ns: SettingsNamespace): unknown {
return this.registrations.get(ns)?.resolved
}
/**
* Merge a patch into one registered namespace's user layer, validate the
* resolved candidate, persist through the provider, then commit and emit.
* A validation failure rejects before anything is persisted.
* @param ns - the registered namespace to update.
* @param patch - plain-object patch over the user section.
*/
async update(ns: SettingsNamespace, patch: object): Promise<void> {
const registration = this.registrations.get(ns)
if (registration === undefined) {
throw new Error(`settings namespace "${ns}" is not registered`)
}
if (!this.writable) {
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
}
if (!isPlainObject(patch)) {
throw new TypeError(`settings update for "${ns}" must be a plain object patch`)
}
const section = mergeLayers(this.section(ns) ?? {}, patch) as Record<string, unknown>
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
await this.persist(ns, section)
this.document[ns] = section
this.commit(registration, next, 'update')
}
/**
* Provider hook: commit a complete raw document observed in storage. Each
* registered namespace re-resolves; an invalid section keeps that
* namespace's last good value and warns, other namespaces still commit.
* @param doc - the detached raw document (unregistered sections preserved).
* @param source - change origin; defaults to `provider`.
*/
protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
this.document = doc
for (const registration of this.registrations.values()) {
let next: unknown
try {
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
} catch (error) {
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
this.ctx.logger.warn(error)
continue
}
this.commit(registration, next, source)
}
}
/** Read one namespace's raw user section, rejecting non-object sections. */
private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
const section = this.document[ns]
if (section === undefined) return undefined
if (!isPlainObject(section)) {
throw new TypeError(`settings section "${ns}" must be an object of keys`)
}
return section
}
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
// The merged candidate is untyped by construction; the schema call is the
// runtime validation that admits it into T.
return schema(mergeLayers(base, section) as never)
}
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
const prev = registration.resolved
if (deepEqual(next, prev)) return
registration.resolved = next
for (const watcher of [...registration.watchers]) {
try {
watcher(next as never, prev as never)
} catch (error) {
this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns)
this.ctx.logger.warn(error)
}
}
this.ctx.emit('settings/updated', registration.ns, next, prev, source)
}
}
export default Settings

View File

@@ -0,0 +1,41 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
* @module @deepseek-ai/dsh-settings/invariant
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
/** Cordis companion plugin name. */
export const name = 'settings-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Install the commit-event contract: `settings/updated` fires only for a
* currently registered namespace and only when the resolved value changed.
*/
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
ctx.on('settings/updated', (ns, next, prev) => {
const settings = ctx.get('settings')
if (settings === undefined) {
fail(`settings/updated for "${ns}" emitted without a live settings service`)
}
if (settings.get(ns) === undefined) {
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
}
if (JSON.stringify(next) === JSON.stringify(prev)) {
fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
}
})
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SettingsInvariant from '../src/invariant.ts'
import { settingsNamespace } from '../src/index.ts'
import { MemorySettings } from './memory.ts'
async function setup(withProvider: boolean): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(SettingsInvariant)
if (withProvider) await ctx.plugin(MemorySettings)
return ctx
}
describe('settings invariants', () => {
it('fails a settings/updated emission without a live settings service', async () => {
const ctx = await setup(false)
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
}).toThrow(/without a live settings service/)
})
it('fails a settings/updated emission for an unregistered namespace', async () => {
const ctx = await setup(true)
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
}).toThrow(/unregistered/)
})
it('fails a settings/updated emission without a resolved-value change', async () => {
const ctx = await setup(true)
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
theme: z.string().default('dark'),
}))
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
}).toThrow(/without a resolved-value change/)
})
})

View File

@@ -0,0 +1,53 @@
/**
* In-memory settings provider fixture: the smallest real subclass of the seam,
* used by the base-class behavior suite in place of a file- or network-backed
* provider. Kept in `tests/` because production providers live in their own
* packages.
*/
import { Service } from 'cordis'
import { Settings, type SettingsNamespace } from '../src/index.ts'
/** In-memory provider exposing the protected seam hooks to tests. */
export class MemorySettings extends Settings {
/** Raw document the provider "storage" currently holds. */
doc: Record<string, unknown>
/** Every persist() call observed, in order. */
persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown> }> = []
/** When false, update() must reject before reaching persist(). */
writableFlag: boolean
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
doc?: Record<string, unknown>
writable?: boolean
}) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
this.writableFlag = options?.writable ?? true
}
get writable(): boolean {
return this.writableFlag
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.persisted.push({ ns, section: structuredClone(section) })
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
/** Simulate an external storage change reaching the provider. */
pushExternal(doc: Record<string, unknown>): void {
this.doc = structuredClone(doc)
this.publish(structuredClone(doc))
}
async* [Service.init](): AsyncGenerator<() => void, void, void> {
this.publish(await this.load())
yield () => { this.persisted.length = 0 }
}
}

View File

@@ -0,0 +1,307 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
import { MemorySettings } from './memory.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
interface NestedConfig {
retry: { attempts: number; delayMs: number }
tags: string[]
}
const NestedSchema: z<NestedConfig> = z.object({
retry: z.object({
attempts: z.number().default(2),
delayMs: z.number().default(100),
}),
tags: z.array(z.string()).default(['default']),
})
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
const ctx = new Context()
await ctx.plugin(MemorySettings, options)
const provider = ctx.get('settings') as MemorySettings
return { ctx, provider }
}
/** Record every settings/updated emission. */
function recordUpdates(ctx: Context) {
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
ctx.on('settings/updated', (ns, next, prev, source) => {
events.push({ ns, next, prev, source })
})
return events
}
describe('settingsNamespace', () => {
it('brands lowercase kebab-case names', () => {
expect(settingsNamespace('ui-theme')).toBe('ui-theme')
})
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
expect(() => settingsNamespace(value)).toThrow(TypeError)
})
})
describe('registration', () => {
it('resolves schema defaults, then composition base, then the user layer', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
// theme: user layer wins; fontSize: base wins over the schema default.
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
})
it('rejects a duplicate namespace loud', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
.toThrow(/already registered/)
})
it('fails registration when the stored section is invalid for the schema', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
})
it('fails registration when the stored section is not an object', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
.toThrow(/must be an object/)
})
it('describes registered namespaces with schema JSON, value, and applies', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
const descriptors = ctx.settings.describe()
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
['ui-theme', 'live'],
['workspace', 'restart'],
])
expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 })
// schemastery's canonical wire form: a { uid, refs } envelope whose root ref
// is the object schema — the shape schema-driven form UIs reconstruct from.
const serialized = descriptors[0]!.schema as { uid: number; refs: Record<string, { type: string }> }
expect(serialized.refs[String(serialized.uid)]?.type).toBe('object')
})
it('reads undefined for an unregistered namespace', async () => {
const { ctx } = await boot()
expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
})
it('hands out frozen resolved values', async () => {
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
const value = scope.get()
expect(Object.isFrozen(value)).toBe(true)
expect(Object.isFrozen(value.retry)).toBe(true)
expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError)
})
it('removes the namespace and its observers when the registrant fiber disposes', async () => {
const { ctx, provider } = await boot()
const seen: unknown[] = []
let scope: SettingsScope<ThemeConfig> | undefined
const fiber = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch(next => seen.push(next))
},
})
await fiber
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
await fiber.dispose()
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
expect(ctx.settings.describe()).toEqual([])
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(seen).toEqual([])
// The namespace is free again, and re-registration resolves the user layer
// that kept living in storage while nobody owned the namespace.
const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
})
})
describe('update', () => {
it('persists the merged user section without baking in the base layer', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
await scope.update({ theme: 'dark' })
expect(provider.persisted).toEqual([
{ ns: 'ui-theme', section: { theme: 'dark' } },
])
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
})
it('deep-merges nested objects and replaces arrays wholesale', async () => {
const { ctx, provider } = await boot({
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
})
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
expect(provider.persisted[0]!.section).toEqual({
retry: { attempts: 7, delayMs: 300 },
tags: ['c'],
})
expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] })
})
it('commits, notifies watchers, and emits with source update', async () => {
const { ctx } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
await scope.update({ theme: 'light' })
expect(watcher).toHaveBeenCalledWith(
{ theme: 'light', fontSize: 14 },
{ theme: 'dark', fontSize: 14 },
)
expect(events).toEqual([{
ns: 'ui-theme',
next: { theme: 'light', fontSize: 14 },
prev: { theme: 'dark', fontSize: 14 },
source: 'update',
}])
})
it('rejects an invalid patch before persisting anything', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
expect(provider.persisted).toEqual([])
expect(events).toEqual([])
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: undefined, fontSize: 18 })
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
})
it('rejects a non-object patch', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update([1])).rejects.toThrow(TypeError)
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
})
it('accepts a null-prototype patch object', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
patch.fontSize = 18
await scope.update(patch)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('rejects an unregistered namespace', async () => {
const { ctx } = await boot()
await expect(ctx.settings.update(settingsNamespace('missing'), {}))
.rejects.toThrow(/not registered/)
})
it('rejects on a read-only provider before reaching persist', async () => {
const { ctx, provider } = await boot({ writable: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
expect(provider.persisted).toEqual([])
})
})
describe('publish', () => {
it('notifies watchers of an external change with source provider', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(watcher).toHaveBeenCalledWith(
{ theme: 'light', fontSize: 14 },
{ theme: 'dark', fontSize: 14 },
)
expect(events[0]!.source).toBe('provider')
})
it('stays silent when the resolved value is deep-equal', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(watcher).not.toHaveBeenCalled()
expect(events).toEqual([])
})
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
provider.pushExternal({
'ui-theme': { fontSize: 'broken' },
workspace: { retry: { attempts: 9 } },
})
expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 })
expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] })
expect(events.map(event => event.ns)).toEqual(['workspace'])
})
it('recovers from a bad section once storage turns valid again', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
})
describe('watch', () => {
it('stops after its disposer runs', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
const dispose = scope.watch(watcher)
dispose()
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(watcher).not.toHaveBeenCalled()
})
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch(() => { throw new Error('watcher boom') })
const second = vi.fn()
scope.watch(second)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(second).toHaveBeenCalledTimes(1)
expect(events).toHaveLength(1)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}
]
}