fix(settings): harden seam and provider per review findings

Confirmed and fixed, each with a regression test that failed first:

- Concurrent update() lost patches (merge over one stale snapshot):
  per-namespace serialized write queues; a failed write cannot poison
  the queue for later writers.
- Fixed-name .tmp write followed planted symlinks and kept stale modes:
  random-suffix sibling, exclusive-create (wx), 0600, cleanup on
  failure, then rename.
- A throwing settings/updated listener escaped commit and permanently
  wedged the provider reload chain (rejected refreshTask): commit now
  contains listener failures (INVARIANT-coded errors still propagate),
  async watcher rejections are adopted and contained
  (watch callbacks are officially void | Promise<void>), and the
  provider chains refreshes on a settled tail with an error log.
- No way to remove a user override: scope/service replace(section)
  sets the user section wholesale; replace({}) re-inherits base and
  schema defaults.
- The three-primitive provider contract did not hold (base never
  called load()): the base Service.init loads and publishes once;
  settings-local delegates via yield* super[Service.init]().
- Dispose did not quiesce: teardown flags closed, closes the watcher,
  then awaits queued/in-flight reloads; closed is re-checked across
  await points.
- Invariant now checks the authoritative relation with the seam's own
  deepEqualJson: emitted next must equal settings.get(ns), and
  next/prev must differ structurally (cosmokit dependency dropped).
- New docs/core-data-structures/settings.{md,zh.md} with type-equiv
  blocks + manifest entries; catalog types moved from exemptions to
  LINK_MAP; website page registered.

Both packages stay at per-file 100% coverage.
This commit is contained in:
Yichen Jiang
2026-07-28 18:18:34 +08:00
parent ec0786e099
commit f44b4db1f2
27 changed files with 655 additions and 73 deletions
+106 -17
View File
@@ -7,7 +7,6 @@
*/
import { Context, Service } from 'cordis'
import { deepEqual } from 'cosmokit'
import type z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -59,16 +58,23 @@ 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.
* Observe committed changes to this namespace's resolved value. A callback
* may be async; a rejection is contained and logged like a sync throw.
* @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
watch(callback: (next: T, prev: T) => void | Promise<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>
/**
* Replace this namespace's user section wholesale; absent keys re-inherit
* the composition `base` and schema defaults (`replace({})` resets all).
* @param section - the complete next user section.
*/
replace(section: object): Promise<void>
}
declare module 'cordis' {
@@ -91,6 +97,28 @@ declare module 'cordis' {
}
}
/**
* Deep equality over JSON-shaped data (objects, arrays, primitives) — the
* seam's single change-detection predicate, exported so the invariant
* companion checks exactly the implementation's relation.
* @param a - one JSON-shaped value.
* @param b - the other JSON-shaped value.
* @returns whether the two values are structurally equal.
*/
export function deepEqualJson(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((entry, index) => deepEqualJson(entry, b[index]))
}
const left = a as Record<string, unknown>
const right = b as Record<string, unknown>
const keys = Object.keys(left)
if (keys.length !== Object.keys(right).length) return false
return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
}
/** 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
@@ -128,7 +156,7 @@ interface SettingsRegistration {
base: unknown
applies: SettingsApplies
resolved: unknown
watchers: Set<(next: never, prev: never) => void>
watchers: Set<(next: never, prev: never) => void | Promise<void>>
}
/**
@@ -141,11 +169,22 @@ 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> = {}
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
constructor(ctx: Context) {
super(ctx, 'settings')
}
/**
* Load the provider's document once and publish it before the service
* becomes injectable. Providers with their own init (watchers, connections)
* delegate here first via `yield* super[Service.init]()`.
*/
async* [Service.init](): AsyncGenerator<() => void, void, void> {
this.publish(await this.load())
}
/** Whether {@link update} may persist through this provider. */
abstract readonly writable: boolean
@@ -195,6 +234,7 @@ export abstract class Settings extends Service {
return () => registration.watchers.delete(callback)
},
update: patch => this.update(ns, patch),
replace: section => this.replace(ns, section),
}
}
@@ -223,11 +263,30 @@ export abstract class Settings extends Service {
/**
* 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.
* A validation failure rejects before anything is persisted. Writes to one
* namespace are serialized: concurrent updates apply in call order, each
* merging over the previous write's committed section.
* @param ns - the registered namespace to update.
* @param patch - plain-object patch over the user section.
*/
async update(ns: SettingsNamespace, patch: object): Promise<void> {
return this.write(ns, patch, 'merge')
}
/**
* Replace one registered namespace's user section wholesale, validate,
* persist, then commit and emit. Keys absent from `section` fall back to the
* composition `base` and schema defaults — this is the removal/reset path a
* merge-only patch cannot express (`replace({})` re-inherits everything).
* @param ns - the registered namespace to replace.
* @param section - the complete next user section.
*/
async replace(ns: SettingsNamespace, section: object): Promise<void> {
return this.write(ns, section, 'replace')
}
/** Validate a write, then queue it on the namespace's serialized write chain. */
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
const registration = this.registrations.get(ns)
if (registration === undefined) {
throw new Error(`settings namespace "${ns}" is not registered`)
@@ -235,14 +294,23 @@ export abstract class Settings extends Service {
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`)
if (!isPlainObject(input)) {
throw new TypeError(`settings ${mode === 'merge' ? 'update' : 'replace'} for "${ns}" must be a plain object`)
}
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')
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
// Chain past a failed predecessor: one rejected write must not poison the
// namespace queue for every later caller.
const run = previous.catch(() => undefined).then(async () => {
const section = mode === 'merge'
? mergeLayers(this.section(ns) ?? {}, input) as Record<string, unknown>
: structuredClone(input)
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
await this.persist(ns, section)
this.document[ns] = section
this.commit(registration, next, 'update')
})
this.writeQueues.set(ns, run)
return run
}
/**
@@ -287,17 +355,38 @@ export abstract class Settings extends Service {
/** 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
if (deepEqualJson(next, prev)) return
registration.resolved = next
for (const watcher of [...registration.watchers]) {
try {
watcher(next as never, prev as never)
// A watcher may be async: adopt its promise so a rejection is contained
// here instead of surfacing as an unhandled rejection.
const outcome = watcher(next as never, prev as never) as unknown
if (outcome instanceof Promise) {
outcome.catch((error: unknown) => {
this.warnWatcherFailure(registration.ns, error)
})
}
} catch (error) {
this.ctx.logger.warn('settings: watcher for "%s" failed', registration.ns)
this.ctx.logger.warn(error)
this.warnWatcherFailure(registration.ns, error)
}
}
this.ctx.emit('settings/updated', registration.ns, next, prev, source)
try {
this.ctx.emit('settings/updated', registration.ns, next, prev, source)
} catch (error) {
// Invariant violations are harness-fatal by design; any other listener
// failure is contained so one broken observer cannot wedge the commit
// path (and, through it, a provider's reload loop).
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns)
this.ctx.logger.warn(error)
}
}
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void {
this.ctx.logger.warn('settings: watcher for "%s" failed', ns)
this.ctx.logger.warn(error)
}
}
+10 -3
View File
@@ -5,6 +5,7 @@
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { deepEqualJson } from './index.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
@@ -15,7 +16,9 @@ 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.
* currently registered namespace, only when the resolved value changed, and
* only with the service's authoritative resolved value — all judged with the
* seam's own equality predicate.
*/
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
ctx.on('settings/updated', (ns, next, prev) => {
@@ -23,10 +26,14 @@ const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
if (settings === undefined) {
fail(`settings/updated for "${ns}" emitted without a live settings service`)
}
if (settings.get(ns) === undefined) {
const current = settings.get(ns)
if (current === undefined) {
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
}
if (JSON.stringify(next) === JSON.stringify(prev)) {
if (!deepEqualJson(current, next)) {
fail(`settings/updated for "${ns}" does not match the authoritative resolved value`)
}
if (deepEqualJson(next, prev)) {
fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
}
})