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:
6
packages/settings/README.i18n.yaml
Normal file
6
packages/settings/README.i18n.yaml
Normal 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
|
||||
12
packages/settings/README.md
Normal file
12
packages/settings/README.md
Normal 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.
|
||||
12
packages/settings/README.zh.md
Normal file
12
packages/settings/README.zh.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# settings/ — 用户设置能力族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx key |
|
||||
|---|---|---|
|
||||
| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` |
|
||||
| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) |
|
||||
|
||||
接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。
|
||||
6
packages/settings/settings-local/README.i18n.yaml
Normal file
6
packages/settings/settings-local/README.i18n.yaml
Normal 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
|
||||
36
packages/settings/settings-local/README.md
Normal file
36
packages/settings/settings-local/README.md
Normal 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.
|
||||
36
packages/settings/settings-local/README.zh.md
Normal file
36
packages/settings/settings-local/README.zh.md
Normal 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 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
|
||||
- **按内容抑制自写。** 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 层的延后特性。
|
||||
46
packages/settings/settings-local/package.json
Normal file
46
packages/settings/settings-local/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
226
packages/settings/settings-local/src/index.ts
Normal file
226
packages/settings/settings-local/src/index.ts
Normal 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
|
||||
31
packages/settings/settings-local/src/invariant.ts
Normal file
31
packages/settings/settings-local/src/invariant.ts
Normal 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 */
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
254
packages/settings/settings-local/tests/local.spec.ts
Normal file
254
packages/settings/settings-local/tests/local.spec.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
118
packages/settings/settings-local/tests/watcher.spec.ts
Normal file
118
packages/settings/settings-local/tests/watcher.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
30
packages/settings/settings-local/tsconfig.json
Normal file
30
packages/settings/settings-local/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/settings/settings/README.i18n.yaml
Normal file
6
packages/settings/settings/README.i18n.yaml
Normal 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
|
||||
35
packages/settings/settings/README.md
Normal file
35
packages/settings/settings/README.md
Normal 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.
|
||||
35
packages/settings/settings/README.zh.md
Normal file
35
packages/settings/settings/README.zh.md
Normal 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 上的 effect:dispose 该 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')` 字段脱敏。
|
||||
41
packages/settings/settings/package.json
Normal file
41
packages/settings/settings/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
304
packages/settings/settings/src/index.ts
Normal file
304
packages/settings/settings/src/index.ts
Normal 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
|
||||
41
packages/settings/settings/src/invariant.ts
Normal file
41
packages/settings/settings/src/invariant.ts
Normal 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))
|
||||
41
packages/settings/settings/tests/invariant.spec.ts
Normal file
41
packages/settings/settings/tests/invariant.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
53
packages/settings/settings/tests/memory.ts
Normal file
53
packages/settings/settings/tests/memory.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
307
packages/settings/settings/tests/settings.spec.ts
Normal file
307
packages/settings/settings/tests/settings.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
27
packages/settings/settings/tsconfig.json
Normal file
27
packages/settings/settings/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user