Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a
# Conflicts: # docs/config-catalog.md # packages/llm/llm-pi-ai/src/config.ts # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm-pi-ai/tests/sdk-options.spec.ts
This commit is contained in:
@@ -44,6 +44,26 @@ export interface SettingsRegisterOptions<T> {
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
/**
|
||||
* Reject a resolved section the owner could not act on, for constraints its
|
||||
* schema cannot express — a cross-field requirement, or one field's validity
|
||||
* depending on another's. Throwing here refuses the *write* that produced the
|
||||
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
|
||||
* something that would silently disable the owner.
|
||||
*
|
||||
* Kept separate from the schema because the schema is also what a
|
||||
* configuration surface renders and what an absent section resolves through;
|
||||
* folding a cross-field check into it would change both.
|
||||
*
|
||||
* Once the owner is registered, a stored section that fails this keeps the
|
||||
* namespace's last good value and warns, exactly as a schema failure does,
|
||||
* so an externally edited document cannot strand a running owner. At
|
||||
* registration there is no last good value yet, so a stored section that
|
||||
* already fails rejects the registration itself — again exactly as a schema
|
||||
* failure does.
|
||||
* @param value - the resolved section, schema-valid by construction.
|
||||
*/
|
||||
validate?: (value: T) => void
|
||||
}
|
||||
|
||||
/** One registered namespace as surfaced to configuration UIs. */
|
||||
@@ -343,6 +363,8 @@ interface SettingsRegistration {
|
||||
schema: z<unknown>
|
||||
base: unknown
|
||||
applies: SettingsApplies
|
||||
/** Owner-supplied check for constraints the schema cannot express. */
|
||||
validate?: (value: unknown) => void
|
||||
resolved: unknown
|
||||
/**
|
||||
* Monotonic counter over this namespace's RAW user section — bumped by any
|
||||
@@ -456,7 +478,10 @@ export abstract class Settings extends Service {
|
||||
schema: schema as z<unknown>,
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
|
||||
...options?.validate === undefined
|
||||
? {}
|
||||
: { validate: options.validate as (value: unknown) => void },
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
|
||||
revision: 0,
|
||||
watchers: new Set(),
|
||||
}
|
||||
@@ -642,7 +667,7 @@ export abstract class Settings extends Service {
|
||||
: mode === 'replace'
|
||||
? snapshot
|
||||
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
|
||||
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
|
||||
@@ -684,7 +709,7 @@ export abstract class Settings extends Service {
|
||||
for (const registration of this.registrations.values()) {
|
||||
let next: unknown
|
||||
try {
|
||||
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
|
||||
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
|
||||
this.ctx.logger.warn(error)
|
||||
@@ -706,10 +731,19 @@ export abstract class Settings extends Service {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
private resolve<T>(
|
||||
schema: z<T>,
|
||||
base: unknown,
|
||||
section: Record<string, unknown> | undefined,
|
||||
validate?: (value: T) => void,
|
||||
): 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)
|
||||
const value = schema(mergeLayers(base, section) as never)
|
||||
// The owner's own check runs on the admitted value, so it sees defaults
|
||||
// and the composition base exactly as the owner will.
|
||||
validate?.(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,6 +876,12 @@ export interface SettingsSectionHooks<T> {
|
||||
* memoized resolutions — after an attach, a detach, or a committed change.
|
||||
*/
|
||||
onChange(): void
|
||||
/**
|
||||
* Reject a resolved section this consumer could not act on, for constraints
|
||||
* its schema cannot express. See {@link SettingsRegisterOptions.validate}.
|
||||
* @param value - the resolved section, schema-valid by construction.
|
||||
*/
|
||||
validate?: (value: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -865,7 +905,10 @@ export function installSettingsSection<T>(
|
||||
hooks: SettingsSectionHooks<T>,
|
||||
): void {
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const scope = sctx.settings.register(ns, schema, { base: entry })
|
||||
const scope = sctx.settings.register(ns, schema, {
|
||||
base: entry,
|
||||
...hooks.validate === undefined ? {} : { validate: hooks.validate },
|
||||
})
|
||||
hooks.setSource(() => scope.get())
|
||||
sctx.effect(() => () => {
|
||||
// This disposer runs for two different reasons. A settings provider
|
||||
|
||||
@@ -95,6 +95,44 @@ describe('registration', () => {
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
})
|
||||
|
||||
it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => {
|
||||
const { ctx } = await boot()
|
||||
const ns = settingsNamespace('ui-theme')
|
||||
// A constraint the schema cannot express: this owner cannot serve a size
|
||||
// it considers unreadable, whatever the schema admits.
|
||||
const scope = ctx.settings.register(ns, ThemeSchema, {
|
||||
validate: (value) => {
|
||||
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
|
||||
},
|
||||
})
|
||||
const before = scope.get()
|
||||
|
||||
await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/)
|
||||
expect(scope.get()).toEqual(before)
|
||||
|
||||
// An externally edited document must not strand the owner: the namespace
|
||||
// keeps its last good value, exactly as a schema failure would.
|
||||
;(ctx.settings as unknown as { publish(doc: Record<string, unknown>): void })
|
||||
.publish({ 'ui-theme': { fontSize: 4 } })
|
||||
expect(scope.get()).toEqual(before)
|
||||
|
||||
await ctx.settings.update(ns, { fontSize: 18 })
|
||||
expect(scope.get()).toMatchObject({ fontSize: 18 })
|
||||
})
|
||||
|
||||
it('fails the registration itself when the already-stored section is unserviceable', async () => {
|
||||
// The other direction of the same contract: `register` resolves inline, so
|
||||
// at cold start there is no last good value to keep. A stored section the
|
||||
// owner cannot serve therefore refuses the registration rather than
|
||||
// mounting an owner over configuration it rejects.
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
validate: (value) => {
|
||||
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
|
||||
},
|
||||
})).toThrow(/unreadable/)
|
||||
})
|
||||
|
||||
it('rejects a duplicate namespace loud', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
|
||||
Reference in New Issue
Block a user