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/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