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:
@@ -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))
|
||||
Reference in New Issue
Block a user