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

Two-package capability family mirroring session-persistence/:

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

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

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

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