refactor(llm-pi-ai): reuse settings validation for startup recovery

This commit is contained in:
Yichen Jiang
2026-09-08 10:47:05 +08:00
parent 642b538fe0
commit a12d30df2a
18 changed files with 52 additions and 104 deletions
@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
2026-09-07-pi-ai-settings-catalog-recovery.md: 1af5a3cb5bd0a406103490cd1b87951f1956e6cd
2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 3cab1f939b7d1589b17ddf82283dc1beae6509fc
2026-09-07-pi-ai-settings-catalog-recovery.md: 1cf4fd1d2c062f79e5b98981d8fece919580f55c
2026-09-07-pi-ai-settings-catalog-recovery.zh.md: e2190bbd78875234a2818ebf0851de0a163659e7
@@ -10,9 +10,9 @@ An installed pi-ai catalog can change the validity of unchanged user settings. O
## Decision
Settings separates shared `validate` checks from `validateWrite(next, previous)`. The latter runs inside the namespace's write queue, after resolution and before persistence, for update, replacement, and path mutation. Both values include the composition base. Registration and external reload do not invoke write-only checks; existing consumers retain their shared-validation behavior.
The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged.
The pi-ai consumer retains catalog diagnostics when reading stored profiles, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
@@ -32,8 +32,8 @@ This extends the [provider-routed adapter decision](../architecture/2026-07-14-p
## Consequences
Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. The settings write hook adds no storage format or session event; provider metadata gains one optional diagnostic field.
Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. No settings API, storage format, or session event is added; provider metadata gains one optional diagnostic field.
## Testing
Settings tests cover write-only validation, persistence refusal, resolved previous values, and external reload. Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior.
Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior.
@@ -10,9 +10,9 @@ English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.md)
## Decision
Settings 将共用的 `validate` 校验与 `validateWrite(next, previous)` 分开。后者在命名空间写入队列内部、解析之后、持久化之前执行,覆盖更新、整体替换和路径修改。两个值均包含组合基础层。注册和外部重载不执行仅写入校验;现有消费者保留其共用校验行为
pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变
pi-ai 消费者读取已存储 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
@@ -32,8 +32,8 @@ Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆
## Consequences
依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。Settings 写入钩子不增加存储格式或 session 事件;提供方元数据新增一个可选诊断字段。
依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。不增加 Settings API、存储格式或 session 事件;提供方元数据新增一个可选诊断字段。
## Testing
Settings 测试覆盖仅写入校验、持久化拒绝、已解析前值与外部重载。适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。
适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。
+2 -2
View File
@@ -2,5 +2,5 @@
# 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 docs/subsystems/settings.md
settings.md: 7d34bfa34471bc01475e147c5b3bbde74d0284ec
settings.zh.md: a3562187545151bed4a4b582a94f6a92d0bc95e1
settings.md: d8e3cbc46eb697315d4938b696e921a0c2e11828
settings.zh.md: 772fd7832ff80cab9444c16c9a1dc7ae1875d51e
-8
View File
@@ -46,14 +46,6 @@ interface SettingsRegisterOptions<T> {
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
/**
* Check an in-process write after resolution and before persistence. Stored
* values do not run this check on registration or external reload, allowing
* the owner to expose repairable configuration after dependencies change.
* @param value - next resolved section.
* @param previous - current resolved section, including the composition base.
*/
validateWrite?: (value: T, previous: T) => void
}
```
-8
View File
@@ -46,14 +46,6 @@ interface SettingsRegisterOptions<T> {
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
/**
* Check an in-process write after resolution and before persistence. Stored
* values do not run this check on registration or external reload, allowing
* the owner to expose repairable configuration after dependencies change.
* @param value - next resolved section.
* @param previous - current resolved section, including the composition base.
*/
validateWrite?: (value: T, previous: T) => void
}
```
@@ -5501,7 +5501,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SettingsRegisterOptions',
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n validateWrite?: (value: T, previous: T) => void;\n}',
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}',
},
{
name: 'SettingsSecretView',
@@ -5509,7 +5509,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SettingsSectionHooks',
declaration: 'export interface SettingsSectionHooks<T> {\n setSource(current: () => T): void;\n onChange(): void;\n validate?: (value: T) => void;\n validateWrite?: (value: T, previous: T) => void;\n}',
declaration: 'export interface SettingsSectionHooks<T> {\n setSource(current: () => T): void;\n onChange(): void;\n validate?: (value: T) => void;\n}',
},
{
name: 'SettingsUpdateSource',
+2 -2
View File
@@ -2,5 +2,5 @@
# 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/llm/llm-pi-ai/README.md
README.md: e9013b634e8a8b392f88e5e8986ace4a8fefac9b
README.zh.md: 9c91e937ab31793085fa3627b88460817917e58a
README.md: 9ec589315c40ef1a4de90b860f13454da783a2a9
README.zh.md: 22bde6757fba0dabf60d66751283dd261f5854da
+1 -1
View File
@@ -114,7 +114,7 @@ The plugin answers "which models can this provider serve?" for a route a configu
A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` list; an unserviceable profile is refused where it is written, naming the route and model. Failures carry stable codes: a credential that cannot be used fails with `INVALID_CREDENTIAL` naming the route and reference, a route whose `apiKeyEnv` reference resolves to nothing fails with `MISSING_CREDENTIAL`, an unconfigured model fails with `UNKNOWN_MODEL`, and terminal provider failures distinguish `QUOTA` from transient `RATE_LIMIT`. `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers.
Settings writes strictly validate each new or changed provider after merging its composition and user layers. Stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmProviderInfo.configurationError`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading.
Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmProviderInfo.configurationError`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure.
-----
+1 -1
View File
@@ -114,7 +114,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的
pi-ai 不提供的路由需要 `api``baseURL` 与非空 `models` 列表;无法服务的 profile 会在写入处被拒绝,并点名路由与模型。失败携带稳定 code:无法使用的凭据以 `INVALID_CREDENTIAL` 失败并点名路由与引用,`apiKeyEnv` 引用解析为空的路由以 `MISSING_CREDENTIAL` 失败,未配置模型以 `UNKNOWN_MODEL` 失败,终止性提供方失败则区分 `QUOTA` 与暂时性 `RATE_LIMIT``GenerateOptions.stop``UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式 UI 无法跨提供方保证它。
Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmProviderInfo.configurationError` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。
Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmProviderInfo.configurationError` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。
-----
-8
View File
@@ -363,14 +363,6 @@ export function assertServiceable(config: Config, previous?: Config): void {
resolveProfiles(changed)
}
/**
* Check self-contained profile requirements without rejecting stored catalog drift.
* @param config - schema-resolved section read from settings.
*/
export function assertReadable(config: Config): void {
resolveProfiles(config.providers, 'deferred')
}
/** Reject removed pre-release profile fields and name their replacements. */
function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void {
const legacy = source as PiAiProviderProfile & {
+11 -5
View File
@@ -65,7 +65,7 @@ import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
import { PiAiAdapter } from './adapter.ts'
import { authContextFrom, credentialStoreFrom } from './auth.ts'
import { catalogProviderIds } from './catalog.ts'
import { assertReadable, assertServiceable, Config, resolveProfiles } from './config.ts'
import { assertServiceable, Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { discoverModels } from './discovery.ts'
import type { StoredModelDiscoveryProfile } from './discovery.ts'
@@ -293,11 +293,16 @@ export function apply(ctx: Context, config: Config): void {
ensureRegistrationFacts()
ctx.inject(['settings'], (settingsCtx) => {
let registering = true
settingsCtx.settings.installSection(ctx, NS, Config, config, {
// Reading stored catalog drift keeps the repair UI; writes still refuse
// any changed provider whose catalog cannot be served.
validate: assertReadable,
validateWrite: assertServiceable,
validate: (value) => {
// Stored catalog drift must not prevent registration of the repair UI.
if (registering) {
resolveProfiles(value.providers, 'deferred')
} else {
assertServiceable(value, current())
}
},
setSource: (source) => {
current = source
},
@@ -327,5 +332,6 @@ export function apply(ctx: Context, config: Config): void {
}
},
})
registering = false
})
}
@@ -40,14 +40,14 @@ async function home(): Promise<string> {
async function boot(
dir: string,
config: LlmPiAi.Config,
options: { authorization?: boolean } = {},
options: { authorization?: boolean; watchSettings?: boolean } = {},
): Promise<Context> {
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
})
await ctx.plugin(LlmRuntime)
await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: options.watchSettings ?? false })
await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
if (options.authorization === true) await ctx.plugin(AuthorizationService)
await ctx.plugin(LlmPiAi, config)
@@ -75,6 +75,25 @@ describe('login flows in a real composition', () => {
})
describe('request-level dynamic profiles', () => {
it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', async () => {
const dir = await home()
const path = join(dir, 'settings.yaml')
await writeFile(path, JSON.stringify({ [NS]: { providers: { deepseek: {} } } }))
const ctx = await boot(dir, {}, { watchSettings: true })
await writeFile(path, JSON.stringify({ [NS]: { providers: { openrouter: { models: [{ id: '111' }] } } } }))
// The raw section proves the watcher processed the edit even though validation kept the old resolved value.
await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user)
.toEqual({ providers: { openrouter: { models: [{ id: '111' }] } } })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'deepseek' }])
await writeFile(path, JSON.stringify({ [NS]: { providers: {
openrouter: { api: 'openai-completions', models: [{ id: '111' }] },
} } }))
await expect.poll(() => ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }])
expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual(['111'])
})
it('keeps stored catalog failures editable while isolating requests and validating changed providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
+2 -2
View File
@@ -2,5 +2,5 @@
# 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: 2561053b1786fe2fa7f15bbfa80345dad081fc9d
README.zh.md: 2bfa0edc42f76962fb015606f124d07358f99e22
README.md: 5c7313ac5015f64cca6b3d91ee75d44f27ab356c
README.zh.md: 337effed483a1bf28d42b76e95c69bc01f805667
-2
View File
@@ -67,8 +67,6 @@ Literal namespace arguments are checked by TypeScript against the lowercase lett
Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-finite number, or circular reference fails with its `$`-rooted path before anything persists), rejects on a read-only provider, and accepts an optional `expectedRevision`: pass back the `revision` from a descriptor, and a namespace that moved past it refuses the write with `SettingsConflictError` instead of overwriting the writer that landed first.
Owners can supply `validate` for checks shared by registration, reload, and writes, plus `validateWrite(next, previous)` for checks that apply only to in-process writes. Both arguments of the write check are resolved values, including composition defaults; the previous value is the current committed snapshot when the queued write runs. A thrown write check prevents persistence and notifications. Consumers that retain dependency-dependent errors on read can use this hook to reject new mistakes while keeping old settings editable.
### Configuration surfaces
`describe()` returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden), the effect timing, and the namespace's revision. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists.
-2
View File
@@ -67,8 +67,6 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac
每次写入都会拒绝与 JSON 不兼容的数据(`Date``Map``BigInt`、非有限数或循环引用会在任何内容持久化前以 `$` 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 `expectedRevision`:把 descriptor 中的 `revision` 传回,namespace 已越过该值时写入会被 `SettingsConflictError` 拒绝,而不是覆盖先完成写入的一方。
Owner 可以用 `validate` 定义注册、重载与写入共用的校验,并用 `validateWrite(next, previous)` 定义仅用于进程内写入的校验。写入校验的两个参数都是包含组合默认值的已解析配置;前值是排队写入执行时当前已提交的快照。写入校验抛错会阻止持久化与通知。读取时保留依赖相关错误的消费者,可以通过此钩子拒绝新增错误,同时保持旧设置可编辑。
### 配置界面
`describe()` 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 `base``user` 层(字段出现在 `user` 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath``prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。
-20
View File
@@ -71,14 +71,6 @@ export interface SettingsRegisterOptions<T> {
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
/**
* Check an in-process write after resolution and before persistence. Stored
* values do not run this check on registration or external reload, allowing
* the owner to expose repairable configuration after dependencies change.
* @param value - next resolved section.
* @param previous - current resolved section, including the composition base.
*/
validateWrite?: (value: T, previous: T) => void
}
/** One registered namespace as surfaced to configuration UIs. */
@@ -319,7 +311,6 @@ interface SettingsRegistration {
applies: SettingsApplies
/** Owner-supplied check for constraints the schema cannot express. */
validate?: (value: unknown) => void
validateWrite?: (value: unknown, previous: unknown) => void
resolved: unknown
/**
* Monotonic counter over this namespace's RAW user section — bumped by any
@@ -442,9 +433,6 @@ export abstract class SettingsProvider extends Service {
...options?.validate === undefined
? {}
: { validate: options.validate as (value: unknown) => void },
...options?.validateWrite === undefined
? {}
: { validateWrite: options.validateWrite as (value: unknown, previous: unknown) => void },
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
revision: 0,
watchers: new Set(),
@@ -491,7 +479,6 @@ export abstract class SettingsProvider extends Service {
const scope = this.register<Namespace, T>(ns, schema, {
base: entry,
...hooks.validate === undefined ? {} : { validate: hooks.validate },
...hooks.validateWrite === undefined ? {} : { validateWrite: hooks.validateWrite },
})
hooks.setSource(() => scope.get())
this.ctx.effect(() => () => {
@@ -687,7 +674,6 @@ export abstract class SettingsProvider extends Service {
? snapshot
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
registration.validateWrite?.(next, registration.resolved)
await this.persist(ns, section)
// The write reached storage either way; the cache must say so. Commit
// only when this registration is still the namespace owner — a fiber
@@ -902,12 +888,6 @@ export interface SettingsSectionHooks<T> {
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
/**
* Validate only in-process writes; see {@link SettingsRegisterOptions.validateWrite}.
* @param value - next resolved section.
* @param previous - current resolved section.
*/
validateWrite?: (value: T, previous: T) => void
}
export default SettingsProvider
@@ -84,35 +84,6 @@ describe('settings namespace validation', () => {
})
describe('registration', () => {
it('runs write-only validation before persistence while retaining repairable stored values', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { fontSize: 27 } } })
const validateWrite = vi.fn((next: ThemeConfig, _previous: ThemeConfig) => {
if (next.fontSize > 16) throw new Error('font size exceeds current limit')
})
try {
const scope = ctx.settings.register('ui-theme', ThemeSchema, { base: { theme: 'light' }, validateWrite })
expect(scope.get()).toEqual({ theme: 'light', fontSize: 27 })
expect(validateWrite).not.toHaveBeenCalled()
await expect(scope.update({ fontSize: 28 })).rejects.toThrow('current limit')
expect(provider.persisted).toEqual([])
expect(scope.get().fontSize).toBe(27)
provider.pushExternal({ 'ui-theme': { fontSize: 29 } })
expect(scope.get().fontSize).toBe(29)
expect(validateWrite).toHaveBeenCalledTimes(1)
await ctx.settings.mutate('ui-theme', [{ op: 'set', path: ['fontSize'], value: 16 }])
expect(validateWrite).toHaveBeenLastCalledWith(
{ theme: 'light', fontSize: 16 }, { theme: 'light', fontSize: 29 },
)
await scope.replace({ fontSize: 15 })
expect(validateWrite).toHaveBeenLastCalledWith(
{ theme: 'light', fontSize: 15 }, { theme: 'light', fontSize: 16 },
)
expect(provider.persisted).toHaveLength(2)
} finally {
await ctx.fiber.dispose()
}
})
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('ui-theme', ThemeSchema, {