refactor(services): move shared values behind service APIs
This commit is contained in:
parent
6c53fe6e2a
commit
f4e49ccf8f
19 changed files with 307 additions and 259 deletions
|
|
@ -99,10 +99,6 @@ export type {
|
|||
DynamicCordisUndefineReceipt,
|
||||
RequestRunOutcome,
|
||||
} from '@deepseek-ai/dsh-cordis-host-runner/types'
|
||||
// The JSON vocabulary those payloads are built from, re-exported for the same
|
||||
// reason: a Client contribution names what it sends without importing a Host
|
||||
// package, and this assembly is where both planes legitimately meet.
|
||||
export type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
// Credential state vocabulary for the credentials namespace (values never ride it).
|
||||
export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
||||
// Redacted namespace vocabulary for the settings namespace (secrets never ride
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import type {
|
|||
} from '@deepseek-ai/dsh-api-gateway'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
|
||||
// The owner packages' client-safe `./types` exports carry the cordis `Events`
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* type-only.
|
||||
*/
|
||||
|
||||
import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events'
|
||||
import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +16,11 @@ import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protoc
|
|||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
{ event: 'agent-preset/selected', mode: 'emit' },
|
||||
{ event: 'approval/request', mode: 'waterfall' },
|
||||
...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
|
||||
{ event: 'api-session/activity', mode: 'emit' },
|
||||
{ event: 'api-session/added', mode: 'emit' },
|
||||
{ event: 'api-session/error', mode: 'emit' },
|
||||
{ event: 'api-session/removed', mode: 'emit' },
|
||||
{ event: 'api-session/status', mode: 'emit' },
|
||||
{ event: 'commands/change', mode: 'emit' },
|
||||
{ event: 'credentials/reference-updated', mode: 'emit' },
|
||||
{ event: 'cordis/request-run', mode: 'emit' },
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@
|
|||
{
|
||||
"path": "../../util/deque"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
|
|
@ -9,8 +10,7 @@ import {
|
|||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
|
|
@ -73,7 +73,7 @@ export class SessionCommandController {
|
|||
if (request.workspaceId !== undefined && request.cwd !== undefined) {
|
||||
throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
|
||||
}
|
||||
const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
|
||||
const sessionId = request.sessionId ?? brandString<SessionId>(`session-${randomUUID()}`)
|
||||
let workspace: Workspace | undefined
|
||||
if (request.workspaceId !== undefined) {
|
||||
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
|
||||
|
|
@ -236,7 +236,7 @@ export class SessionCommandController {
|
|||
{},
|
||||
)
|
||||
}
|
||||
const childId = SessionId(`session-${randomUUID()}`)
|
||||
const childId = brandString<SessionId>(`session-${randomUUID()}`)
|
||||
const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
|
||||
try {
|
||||
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
|||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
|
||||
Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
SessionControlFrame,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
/** Session Controller events forwarded unchanged through the Remote Event carrier. */
|
||||
export const SESSION_CONTROLLER_REMOTE_EVENTS = [
|
||||
'api-session/activity',
|
||||
'api-session/added',
|
||||
'api-session/error',
|
||||
'api-session/removed',
|
||||
'api-session/status',
|
||||
] as const
|
||||
/** Session Controller events available to a Remote Event assembly. */
|
||||
type SessionControllerRemoteEvent =
|
||||
| 'api-session/activity'
|
||||
| 'api-session/added'
|
||||
| 'api-session/error'
|
||||
| 'api-session/removed'
|
||||
| 'api-session/status'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertRemoteEventSelection extends
|
||||
Record<typeof SESSION_CONTROLLER_REMOTE_EVENTS[number], true> {}
|
||||
Record<SessionControllerRemoteEvent, true> {}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
|||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
|
|
|
|||
|
|
@ -17,13 +17,12 @@ import {
|
|||
openNativePath,
|
||||
openNativeTextFile,
|
||||
} from '@deepseek-ai/dsh-native-command'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
|
||||
import type {
|
||||
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
|
||||
} from '@deepseek-ai/dsh-settings/types'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { z } from 'zod'
|
||||
import { CredentialsController } from './credentials.ts'
|
||||
import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
|
||||
|
|
@ -269,22 +268,15 @@ export class SettingsController extends TypertRemoteService {
|
|||
throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
|
||||
}
|
||||
const settings = this.provider()
|
||||
let branded
|
||||
const namespace = parsed.data.ns
|
||||
try {
|
||||
// A malformed name can address no registration, so it fails exactly as an
|
||||
// unregistered one does.
|
||||
branded = settingsNamespace(parsed.data.ns)
|
||||
} catch (error: unknown) {
|
||||
throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error })
|
||||
}
|
||||
try {
|
||||
if (mode === 'update') await settings.update(branded, input, expectedRevision)
|
||||
else if (mode === 'replace') await settings.replace(branded, input, expectedRevision)
|
||||
else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision)
|
||||
if (mode === 'update') await settings.update(namespace, input, expectedRevision)
|
||||
else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision)
|
||||
else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision)
|
||||
} catch (error: unknown) {
|
||||
throw rejected(ns, error)
|
||||
}
|
||||
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
|
||||
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === namespace)
|
||||
if (descriptor === undefined) {
|
||||
// The write committed but the namespace vanished before this read: only a
|
||||
// concurrent registrant disposal can produce it.
|
||||
|
|
@ -311,6 +303,22 @@ function messageOf(error: unknown): string {
|
|||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
interface SettingsConflict {
|
||||
readonly code: 'SETTINGS_CONFLICT'
|
||||
readonly message: string
|
||||
readonly expected: number
|
||||
readonly actual: number
|
||||
}
|
||||
|
||||
function settingsConflictOf(error: unknown): SettingsConflict | undefined {
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
if (Reflect.get(error, 'code') !== 'SETTINGS_CONFLICT'
|
||||
|| typeof Reflect.get(error, 'message') !== 'string'
|
||||
|| typeof Reflect.get(error, 'expected') !== 'number'
|
||||
|| typeof Reflect.get(error, 'actual') !== 'number') return undefined
|
||||
return error as SettingsConflict
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one seam refusal. A stale writer is its own outcome, not a malformed
|
||||
* request: the client must re-read and re-apply rather than treat the write as
|
||||
|
|
@ -320,11 +328,12 @@ function messageOf(error: unknown): string {
|
|||
* @returns the failure to raise for that refusal.
|
||||
*/
|
||||
function rejected(ns: string, error: unknown): RemoteError {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
const conflict = settingsConflictOf(error)
|
||||
if (conflict !== undefined) {
|
||||
return new RemoteError(
|
||||
'settings/conflict',
|
||||
error.message,
|
||||
{ ns, expected: error.expected, actual: error.actual },
|
||||
conflict.message,
|
||||
{ ns, expected: conflict.expected, actual: conflict.actual },
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
||||
import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import SettingsController from '../src/index.ts'
|
||||
import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
|
||||
|
||||
const NS = settingsNamespace('ui-test')
|
||||
const NS = 'ui-test'
|
||||
|
||||
const Profile = z.object({
|
||||
preference: z.union(['light', 'dark']).default('light'),
|
||||
|
|
@ -47,8 +46,8 @@ class SlotlessSettings extends MemorySettings {
|
|||
|
||||
/** A provider that refuses every write the way a read-only backing store would. */
|
||||
class RefusingSettings extends MemorySettings {
|
||||
override mutate(ns: SettingsNamespace): Promise<void> {
|
||||
return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`))
|
||||
override mutate(): Promise<void> {
|
||||
return Promise.reject(new Error('settings are read-only in this deployment'))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,7 @@ export interface PromptSection {
|
|||
readonly name: string
|
||||
/**
|
||||
* Sections are concatenated in ascending order. Equal orders use code-unit
|
||||
* name order. Repository-owned placements use
|
||||
* {@link FIRST_PARTY_SECTION_ORDER}.
|
||||
* name order.
|
||||
*/
|
||||
readonly order: number
|
||||
/**
|
||||
|
|
@ -119,15 +118,7 @@ export interface PromptAssembly {
|
|||
variables: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparse integer placements for repository-owned prompt sections.
|
||||
*
|
||||
* Adjacent values differ by at least ten to keep the first-party groups sparse
|
||||
* and make accidental collisions mechanically detectable.
|
||||
* External plugins may use any finite order; equal orders are deterministic by
|
||||
* section name.
|
||||
*/
|
||||
export const FIRST_PARTY_SECTION_ORDER = {
|
||||
const SECTION_ORDERS = {
|
||||
HARNESS_IDENTITY: -1000,
|
||||
HARNESS_SOURCE: -900,
|
||||
WEB_SURFACE: -800,
|
||||
|
|
@ -160,17 +151,26 @@ export const FIRST_PARTY_SECTION_ORDER = {
|
|||
STRUCTURED_OUTPUT: 9900,
|
||||
} as const
|
||||
|
||||
/** Name of a centrally allocated prompt-section position. */
|
||||
export type PromptSectionOrderName = keyof typeof SECTION_ORDERS
|
||||
|
||||
const CONTEXT_ORDERS = {
|
||||
SANDBOX_POLICY: 110,
|
||||
APPROVAL_POLICY: 115,
|
||||
SUBAGENT_DELEGATION: 120,
|
||||
} as const
|
||||
|
||||
/** Name of a centrally allocated runtime-context position. */
|
||||
export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS
|
||||
|
||||
/**
|
||||
* The deployment persona's section name and order. Exported because a
|
||||
* The deployment persona's section name. Exported because a
|
||||
* composition can replace this slot — an agent preset shadows the
|
||||
* deployment's persona with its own — and both sides naming the same section
|
||||
* is what makes the replacement work rather than duplicate.
|
||||
*/
|
||||
export const PERSONA_SECTION = 'deployment:persona'
|
||||
|
||||
/** Prompt order of the persona slot. */
|
||||
export const PERSONA_ORDER = FIRST_PARTY_SECTION_ORDER.DEPLOYMENT_PERSONA
|
||||
|
||||
/** Valid variable names: how they are written between the braces. */
|
||||
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
|
|
@ -408,13 +408,13 @@ export class SystemPrompt extends Service {
|
|||
if (config.includeHarnessIdentity ?? true) {
|
||||
this.section({
|
||||
name: 'harness:identity',
|
||||
order: FIRST_PARTY_SECTION_ORDER.HARNESS_IDENTITY,
|
||||
order: this.getSectionOrder('HARNESS_IDENTITY'),
|
||||
text: 'You are an AI agent powered by DeepSeek Harness.',
|
||||
})
|
||||
}
|
||||
this.section({
|
||||
name: PERSONA_SECTION,
|
||||
order: PERSONA_ORDER,
|
||||
order: this.getSectionOrder('DEPLOYMENT_PERSONA'),
|
||||
// The fallback narrows the optional input type; the schema already defaults it.
|
||||
text: config.persona ?? '',
|
||||
})
|
||||
|
|
@ -440,6 +440,24 @@ export class SystemPrompt extends Service {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the centrally owned placement of a repository prompt section.
|
||||
* @param name - stable section placement name.
|
||||
* @returns the section's numeric sort order.
|
||||
*/
|
||||
getSectionOrder(name: PromptSectionOrderName): number {
|
||||
return SECTION_ORDERS[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the centrally owned placement of a repository runtime context.
|
||||
* @param name - stable context placement name.
|
||||
* @returns the context's numeric sort order.
|
||||
*/
|
||||
getContextOrder(name: PromptContextOrderName): number {
|
||||
return CONTEXT_ORDERS[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register ordered dynamic context in the calling context's scope. Scoped
|
||||
* entries shadow global entries with the same name.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SystemPrompt, {
|
||||
AssembleContext, FIRST_PARTY_SECTION_ORDER, PromptAssembly, renderContextSnapshot, renderPrompt,
|
||||
AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt,
|
||||
} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptContextOrderName, PromptSectionOrderName } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Every assembly carries the plugin's own built-ins — `harness:identity`
|
||||
|
|
@ -12,19 +13,41 @@ import SystemPrompt, {
|
|||
*/
|
||||
const BUILT_IN = ['harness:identity', 'deployment:persona']
|
||||
const IDENTITY = 'You are an AI agent powered by DeepSeek Harness.'
|
||||
const SECTION_ORDER_NAMES = [
|
||||
'HARNESS_IDENTITY', 'HARNESS_SOURCE', 'WEB_SURFACE', 'DEPLOYMENT_PERSONA',
|
||||
'PLAN_POLICY', 'TEAM_POLICY', 'PTC_ONLY', 'FILE_REFERENCE', 'TOOL_BASH',
|
||||
'TOOL_PWSH', 'TOOL_READ', 'TOOL_WRITE', 'TOOL_EDIT', 'TOOL_GLOB',
|
||||
'TOOL_GREP', 'TOOL_JOBS', 'TOOL_PTY', 'TOOL_WEB_SEARCH', 'TOOL_WEB_FETCH',
|
||||
'TOOL_LSP', 'TOOL_SESSION_QUERY', 'TOOL_GOAL', 'TOOL_CORDIS', 'TOOL_WORKFLOW',
|
||||
'TOOL_RALPH', 'TOOL_SUBAGENT', 'TOOL_REPORT', 'TOOLS_SDK',
|
||||
'DELIVERABLE_FILE_REFERENCES', 'STRUCTURED_OUTPUT',
|
||||
] as const satisfies readonly PromptSectionOrderName[]
|
||||
const CONTEXT_ORDER_NAMES = [
|
||||
'SANDBOX_POLICY', 'APPROVAL_POLICY', 'SUBAGENT_DELEGATION',
|
||||
] as const satisfies readonly PromptContextOrderName[]
|
||||
function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
|
||||
return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
|
||||
}
|
||||
|
||||
describe('SystemPrompt', () => {
|
||||
it('keeps first-party section placements unique, integral, and at least ten apart', () => {
|
||||
const orders = Object.values(FIRST_PARTY_SECTION_ORDER)
|
||||
it('keeps repository section placements unique, integral, and at least ten apart', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
const orders = SECTION_ORDER_NAMES.map(name => ctx.systemPrompt.getSectionOrder(name))
|
||||
expect(orders.every(Number.isInteger)).toBe(true)
|
||||
expect(new Set(orders).size).toBe(orders.length)
|
||||
const sorted = [...orders].sort((a, b) => a - b)
|
||||
expect(sorted.slice(1).every((order, index) => order - sorted[index]! >= 10)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps repository context placements unique and integral', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
const orders = CONTEXT_ORDER_NAMES.map(name => ctx.systemPrompt.getContextOrder(name))
|
||||
expect(orders.every(Number.isInteger)).toBe(true)
|
||||
expect(new Set(orders).size).toBe(orders.length)
|
||||
})
|
||||
|
||||
describe('built-in sections', () => {
|
||||
it('registers the harness identity and the configured deployment persona', async () => {
|
||||
const ctx = new Context()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type z from '@deepseek-ai/schemastery'
|
||||
import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import { redactSecrets } from './redact.ts'
|
||||
import type { RedactedSecret } from './redact.ts'
|
||||
import type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
|
||||
|
|
@ -17,13 +18,24 @@ export type { RedactedSecret, RedactedValue } from './redact.ts'
|
|||
export type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
|
||||
|
||||
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
|
||||
| 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
|
||||
type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||
type NamespaceCharacter = LowercaseLetter | DecimalDigit | '-'
|
||||
type ValidNamespaceTail<Value extends string> = Value extends ''
|
||||
? true
|
||||
: Value extends `${NamespaceCharacter}${infer Rest}`
|
||||
? ValidNamespaceTail<Rest>
|
||||
: false
|
||||
type SettingsNamespaceInput<Value extends string> = Value extends SettingsNamespace
|
||||
? Value
|
||||
: string extends Value
|
||||
? string
|
||||
: Value extends `${LowercaseLetter}${infer Rest}`
|
||||
? ValidNamespaceTail<Rest> extends true ? Value : never
|
||||
: never
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
function parseSettingsNamespace(value: string): SettingsNamespace {
|
||||
if (!NAMESPACE_PATTERN.test(value)) {
|
||||
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
|
||||
}
|
||||
|
|
@ -134,28 +146,6 @@ declare module '@deepseek-ai/cordis' {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
||||
* Service Definition's single change-detection predicate, exported so the invariant
|
||||
* companion checks exactly the implementation's relation.
|
||||
* @param a - one JSON-compatible value.
|
||||
* @param b - the other JSON-compatible 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]))
|
||||
}
|
||||
|
||||
/**
|
||||
* A write refused because the namespace moved since the caller read it. The
|
||||
* Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
|
||||
|
|
@ -304,13 +294,6 @@ function mergeLayers(under: unknown, over: unknown): unknown {
|
|||
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 registered watcher and its serialized invocation chain. */
|
||||
interface SettingsWatcher {
|
||||
callback: (next: never, prev: never) => void | Promise<void>
|
||||
|
|
@ -431,29 +414,35 @@ export abstract class SettingsProvider extends Service {
|
|||
* @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.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
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`)
|
||||
register<const Namespace extends string, T>(
|
||||
ns: Namespace & SettingsNamespaceInput<Namespace>,
|
||||
schema: z<T>,
|
||||
options?: SettingsRegisterOptions<T>,
|
||||
): SettingsScope<T> {
|
||||
const parsedNs = parseSettingsNamespace(ns)
|
||||
if (this.registrations.has(parsedNs)) {
|
||||
throw new Error(`settings namespace "${parsedNs}" is already registered`)
|
||||
}
|
||||
const registration: SettingsRegistration = {
|
||||
ns,
|
||||
ns: parsedNs,
|
||||
schema: schema as z<unknown>,
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
...options?.validate === undefined
|
||||
? {}
|
||||
: { validate: options.validate as (value: unknown) => void },
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
|
||||
revision: 0,
|
||||
watchers: new Set(),
|
||||
}
|
||||
this.ctx.effect(() => {
|
||||
this.registrations.set(ns, registration)
|
||||
this.registrations.set(parsedNs, registration)
|
||||
// TODO(settings-registration-quiescence): Deactivate every watcher and await
|
||||
// its tail on disposal so callbacks cannot outlive the registrant fiber.
|
||||
return () => this.registrations.delete(ns)
|
||||
}, `settings.register(${JSON.stringify(String(ns))})`)
|
||||
return () => this.registrations.delete(parsedNs)
|
||||
}, `settings.register(${JSON.stringify(String(parsedNs))})`)
|
||||
return {
|
||||
get: () => registration.resolved as T,
|
||||
watch: (callback) => {
|
||||
|
|
@ -464,11 +453,48 @@ export abstract class SettingsProvider extends Service {
|
|||
registration.watchers.delete(watcher)
|
||||
}
|
||||
},
|
||||
update: patch => this.update(ns, patch),
|
||||
replace: section => this.replace(ns, section),
|
||||
update: patch => this.update(parsedNs, patch),
|
||||
replace: section => this.replace(parsedNs, section),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one optional-settings consumer to this provider. The consumer
|
||||
* registers its composition entry as the base layer while this provider is
|
||||
* present, then falls back to that entry if the provider detaches.
|
||||
* @param owner - consumer context whose unload suppresses fallback work.
|
||||
* @param ns - consumer-owned settings namespace.
|
||||
* @param schema - schema resolving the namespace.
|
||||
* @param entry - composition entry used as the base and fallback value.
|
||||
* @param hooks - source sink, change notification, and optional validation.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
installSection<const Namespace extends string, T>(
|
||||
owner: Context,
|
||||
ns: Namespace & SettingsNamespaceInput<Namespace>,
|
||||
schema: z<T>,
|
||||
entry: T,
|
||||
hooks: SettingsSectionHooks<T>,
|
||||
): void {
|
||||
const scope = this.register<Namespace, T>(ns, schema, {
|
||||
base: entry,
|
||||
...hooks.validate === undefined ? {} : { validate: hooks.validate },
|
||||
})
|
||||
hooks.setSource(() => scope.get())
|
||||
this.ctx.effect(() => () => {
|
||||
// Losing the provider leaves the consumer running; unloading the
|
||||
// consumer does not, so only the former needs fallback work.
|
||||
if (isUnloading(owner)) return
|
||||
hooks.setSource(() => entry)
|
||||
hooks.onChange()
|
||||
})
|
||||
hooks.onChange()
|
||||
scope.watch(() => {
|
||||
if (isUnloading(owner)) return
|
||||
hooks.onChange()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for configuration surfaces, including
|
||||
* the composition `base` and raw user layers so a form can mark which fields
|
||||
|
|
@ -515,9 +541,10 @@ export abstract class SettingsProvider extends Service {
|
|||
* Read one registered namespace's resolved value.
|
||||
* @param ns - the namespace to read.
|
||||
* @returns the resolved value, or `undefined` while unregistered.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
get(ns: SettingsNamespace): unknown {
|
||||
return this.registrations.get(ns)?.resolved
|
||||
get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown {
|
||||
return this.registrations.get(parseSettingsNamespace(ns))?.resolved
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -530,9 +557,14 @@ export abstract class SettingsProvider extends Service {
|
|||
* @param patch - plain-object patch over the user section.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void> {
|
||||
return this.write(ns, patch, 'merge', expectedRevision)
|
||||
async update<const Namespace extends string>(
|
||||
ns: Namespace & SettingsNamespaceInput<Namespace>,
|
||||
patch: object,
|
||||
expectedRevision?: number,
|
||||
): Promise<void> {
|
||||
return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -544,9 +576,14 @@ export abstract class SettingsProvider extends Service {
|
|||
* @param section - the complete next user section.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void> {
|
||||
return this.write(ns, section, 'replace', expectedRevision)
|
||||
async replace<const Namespace extends string>(
|
||||
ns: Namespace & SettingsNamespaceInput<Namespace>,
|
||||
section: object,
|
||||
expectedRevision?: number,
|
||||
): Promise<void> {
|
||||
return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -560,18 +597,24 @@ export abstract class SettingsProvider extends Service {
|
|||
* @param ops - ordered path edits; later ops observe earlier ones.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
||||
*/
|
||||
async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void> {
|
||||
if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`)
|
||||
async mutate<const Namespace extends string>(
|
||||
ns: Namespace & SettingsNamespaceInput<Namespace>,
|
||||
ops: readonly SettingsPathOp[],
|
||||
expectedRevision?: number,
|
||||
): Promise<void> {
|
||||
const parsedNs = parseSettingsNamespace(ns)
|
||||
if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`)
|
||||
for (const op of ops) {
|
||||
if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`)
|
||||
throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`)
|
||||
}
|
||||
if (!Array.isArray(op['path']) || (op['path'] as unknown[]).some(part => typeof part !== 'string')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`)
|
||||
throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`)
|
||||
}
|
||||
}
|
||||
return this.write(ns, ops, 'mutate', expectedRevision)
|
||||
return this.write(parsedNs, ops, 'mutate', expectedRevision)
|
||||
}
|
||||
|
||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||
|
|
@ -825,7 +868,7 @@ function isUnloading(ctx: Context): boolean {
|
|||
return state === FIBER_UNLOADING || state === FIBER_DISPOSED
|
||||
}
|
||||
|
||||
/** Hooks a consumer hands to {@link installSettingsSection}. */
|
||||
/** Hooks a consumer hands to {@link SettingsProvider.installSection}. */
|
||||
export interface SettingsSectionHooks<T> {
|
||||
/**
|
||||
* Receive the active configuration source: the resolved settings scope
|
||||
|
|
@ -847,53 +890,4 @@ export interface SettingsSectionHooks<T> {
|
|||
validate?: (value: T) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the canonical optional-settings consumer wiring: while a settings
|
||||
* service exists, register `ns` with the consumer's composition entry as the
|
||||
* `base` layer and point the source thunk at the resolved scope; when the
|
||||
* service goes away (disposal, provider reload), fall back to the entry so
|
||||
* the consumer keeps working exactly as composed. The registration rides the
|
||||
* scoped fiber, so no settings service ever mounted means none of this runs.
|
||||
* @param ctx - consumer plugin context owning the wiring.
|
||||
* @param ns - the consumer-owned settings namespace.
|
||||
* @param schema - schema resolving the namespace (typically the plugin Config).
|
||||
* @param entry - the consumer's composition entry config, used as `base`.
|
||||
* @param hooks - source sink and change notification.
|
||||
*/
|
||||
export function installSettingsSection<T>(
|
||||
ctx: Context,
|
||||
ns: SettingsNamespace,
|
||||
schema: z<T>,
|
||||
entry: T,
|
||||
hooks: SettingsSectionHooks<T>,
|
||||
): void {
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
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
|
||||
// detaching leaves the consumer running, so it must fall back to its
|
||||
// composition entry and re-judge what it derived. The consumer's own
|
||||
// unload runs it too — and there `onChange` would re-register routes
|
||||
// and touch resources the teardown is releasing, so the fallback is
|
||||
// pointless and the notification actively harmful.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.setSource(() => entry)
|
||||
hooks.onChange()
|
||||
})
|
||||
hooks.onChange()
|
||||
scope.watch(() => {
|
||||
// A stored change landing while the consumer unloads reaches the watcher
|
||||
// before the registration is released, and `onChange` is exactly as
|
||||
// harmful here as in the disposer above: it re-registers routes against
|
||||
// a fiber whose resources are being let go.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.onChange()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default SettingsProvider
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deepEqualJson } from './index.ts'
|
||||
import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as SettingsInvariant from '../src/invariant.ts'
|
||||
import { settingsNamespace } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
async function setup(withProvider: boolean): Promise<Context> {
|
||||
|
|
@ -18,35 +18,35 @@ describe('settings invariants', () => {
|
|||
it('fails a settings/updated emission without a live settings service', async () => {
|
||||
const ctx = await setup(false)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/without a live settings service/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission for an unregistered namespace', async () => {
|
||||
const ctx = await setup(true)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/unregistered/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission without a resolved-value change', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
ctx.settings.register('ui-theme', z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
|
||||
ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'dark' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/without a resolved-value change/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission whose value diverges from the authoritative state', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
ctx.settings.register('ui-theme', z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
// Fabricated next ≠ the service's current resolved value ({theme: 'dark'}).
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update')
|
||||
ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'forged' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/authoritative/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { redactSecrets, settingsNamespace } from '../src/index.ts'
|
||||
import { redactSecrets } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
const Profile = z.object({
|
||||
|
|
@ -104,7 +104,7 @@ describe('redactSecrets', () => {
|
|||
})
|
||||
|
||||
describe('describe() layers and redaction', () => {
|
||||
const NS = settingsNamespace('adapter')
|
||||
const NS = 'adapter'
|
||||
|
||||
async function boot(doc?: Record<string, unknown>) {
|
||||
const ctx = new Context()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { SettingsProvider, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { SettingsProvider, SettingsConflictError, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
/** A provider implementing only the three primitives: the Service Definition owns initialization. */
|
||||
|
|
@ -75,20 +76,17 @@ function recordUpdates(ctx: Context) {
|
|||
return events
|
||||
}
|
||||
|
||||
describe('settingsNamespace', () => {
|
||||
it('brands lowercase kebab-case names', () => {
|
||||
expect(settingsNamespace('ui-theme')).toBe('ui-theme')
|
||||
})
|
||||
|
||||
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
|
||||
expect(() => settingsNamespace(value)).toThrow(TypeError)
|
||||
describe('settings namespace validation', () => {
|
||||
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j at the service', async (value) => {
|
||||
const { ctx } = await boot()
|
||||
expect(() => ctx.settings.register(value, ThemeSchema)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration', () => {
|
||||
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(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
// theme: user layer wins; fontSize: base wins over the schema default.
|
||||
|
|
@ -97,7 +95,7 @@ describe('registration', () => {
|
|||
|
||||
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')
|
||||
const ns = '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, {
|
||||
|
|
@ -126,7 +124,7 @@ describe('registration', () => {
|
|||
// 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, {
|
||||
expect(() => ctx.settings.register('ui-theme', ThemeSchema, {
|
||||
validate: (value) => {
|
||||
if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
|
||||
},
|
||||
|
|
@ -135,26 +133,26 @@ describe('registration', () => {
|
|||
|
||||
it('rejects a duplicate namespace loud', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
ctx.settings.register('ui-theme', ThemeSchema)
|
||||
expect(() => ctx.settings.register('ui-theme', ThemeSchema))
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is invalid for the schema', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
|
||||
expect(() => ctx.settings.register('ui-theme', ThemeSchema)).toThrow()
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is not an object', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
expect(() => ctx.settings.register('ui-theme', ThemeSchema))
|
||||
.toThrow(/must be an object/)
|
||||
})
|
||||
|
||||
it('describes registered namespaces with schema JSON, value, and applies', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
|
||||
ctx.settings.register('ui-theme', ThemeSchema)
|
||||
ctx.settings.register('workspace', NestedSchema, { applies: 'restart' })
|
||||
const descriptors = ctx.settings.describe()
|
||||
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
|
||||
['ui-theme', 'live'],
|
||||
|
|
@ -169,12 +167,12 @@ describe('registration', () => {
|
|||
|
||||
it('reads undefined for an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
|
||||
expect(ctx.settings.get('missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands out frozen resolved values', async () => {
|
||||
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
const scope = ctx.settings.register('workspace', NestedSchema)
|
||||
const value = scope.get()
|
||||
expect(Object.isFrozen(value)).toBe(true)
|
||||
expect(Object.isFrozen(value.retry)).toBe(true)
|
||||
|
|
@ -188,22 +186,22 @@ describe('registration', () => {
|
|||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope = child.settings.register('ui-theme', ThemeSchema)
|
||||
scope.watch((next) => { seen.push(next) })
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
expect(ctx.settings.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
|
||||
expect(ctx.settings.get('ui-theme')).toBeUndefined()
|
||||
expect(ctx.settings.describe()).toEqual([])
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(seen).toEqual([])
|
||||
|
||||
// The namespace is free again, and re-registration resolves the user layer
|
||||
// that kept living in storage while nobody owned the namespace.
|
||||
const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const again = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
|
|
@ -211,7 +209,7 @@ describe('registration', () => {
|
|||
describe('update', () => {
|
||||
it('persists the merged user section without baking in the base layer', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.update({ theme: 'dark' })
|
||||
|
|
@ -225,7 +223,7 @@ describe('update', () => {
|
|||
const { ctx, provider } = await boot({
|
||||
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
const scope = ctx.settings.register('workspace', NestedSchema)
|
||||
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
|
||||
expect(provider.persisted[0]!.section).toEqual({
|
||||
retry: { attempts: 7, delayMs: 300 },
|
||||
|
|
@ -237,7 +235,7 @@ describe('update', () => {
|
|||
it('commits, notifies watchers, and emits with source update', async () => {
|
||||
const { ctx } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
await scope.update({ theme: 'light' })
|
||||
|
|
@ -256,7 +254,7 @@ describe('update', () => {
|
|||
it('rejects an invalid patch before persisting anything', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
|
||||
expect(provider.persisted).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
|
|
@ -268,7 +266,7 @@ describe('update', () => {
|
|||
|
||||
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await scope.update({ theme: undefined, fontSize: 18 })
|
||||
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
|
||||
|
|
@ -276,7 +274,7 @@ describe('update', () => {
|
|||
|
||||
it('rejects a non-object patch', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await expect(scope.update([1])).rejects.toThrow(TypeError)
|
||||
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
|
||||
await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
|
||||
|
|
@ -284,7 +282,7 @@ describe('update', () => {
|
|||
|
||||
it('accepts a null-prototype patch object', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
|
||||
patch.fontSize = 18
|
||||
await scope.update(patch)
|
||||
|
|
@ -293,13 +291,13 @@ describe('update', () => {
|
|||
|
||||
it('rejects an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
await expect(ctx.settings.update(settingsNamespace('missing'), {}))
|
||||
await expect(ctx.settings.update('missing', {}))
|
||||
.rejects.toThrow(/not registered/)
|
||||
})
|
||||
|
||||
it('rejects on a read-only provider before reaching persist', async () => {
|
||||
const { ctx, provider } = await boot({ writable: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
})
|
||||
|
|
@ -325,14 +323,14 @@ describe('review regressions', () => {
|
|||
ctx.on('settings/updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register('ui-theme', ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
|
||||
.toThrow(/forged relation/)
|
||||
})
|
||||
|
||||
it('serializes concurrent updates so neither patch is lost', async () => {
|
||||
const { ctx, provider } = await boot({ persistDelayMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await Promise.all([
|
||||
scope.update({ theme: 'light' }),
|
||||
scope.update({ fontSize: 20 }),
|
||||
|
|
@ -346,7 +344,7 @@ describe('review regressions', () => {
|
|||
ctx.on('settings/updated', () => {
|
||||
throw new Error('listener boom')
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
|
||||
expect(scope.get().theme).toBe('light')
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
|
||||
|
|
@ -355,7 +353,7 @@ describe('review regressions', () => {
|
|||
|
||||
it('contains an async watcher rejection', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
scope.watch(async () => {
|
||||
throw new Error('async watcher boom')
|
||||
})
|
||||
|
|
@ -369,13 +367,13 @@ describe('review regressions', () => {
|
|||
it('loads the provider document through the base init without provider boilerplate', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
|
||||
})
|
||||
|
||||
it('replaces the user section wholesale so overrides can be removed', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.replace({ theme: 'light' })
|
||||
|
|
@ -396,7 +394,7 @@ describe('second review regressions', () => {
|
|||
})
|
||||
const second = vi.fn()
|
||||
ctx.on('settings/updated', second)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register('ui-theme', ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
|
@ -407,7 +405,7 @@ describe('second review regressions', () => {
|
|||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope = child.settings.register('ui-theme', ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
|
|
@ -423,7 +421,7 @@ describe('second review regressions', () => {
|
|||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope = child.settings.register('ui-theme', ThemeSchema)
|
||||
scope.watch(watcher)
|
||||
},
|
||||
})
|
||||
|
|
@ -443,7 +441,7 @@ describe('second review regressions', () => {
|
|||
it('drains in-flight writes at service dispose and rejects later ones', async () => {
|
||||
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const service = ctx.settings
|
||||
const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = service.register('ui-theme', ThemeSchema)
|
||||
const pending = scope.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
|
|
@ -452,7 +450,7 @@ describe('second review regressions', () => {
|
|||
const persistedAtDispose = provider.persisted.length
|
||||
expect(persistedAtDispose).toBe(1)
|
||||
// …and afterwards nothing writes and new writes reject.
|
||||
await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
|
||||
await expect(service.update('ui-theme', { theme: 'dark' }))
|
||||
.rejects.toThrow(/disposed|not registered/)
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(provider.persisted.length).toBe(persistedAtDispose)
|
||||
|
|
@ -460,7 +458,7 @@ describe('second review regressions', () => {
|
|||
|
||||
it('serializes invocations of one async watcher in commit order', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const applied: number[] = []
|
||||
let firstCall = true
|
||||
scope.watch(async (next) => {
|
||||
|
|
@ -481,14 +479,14 @@ describe('second review regressions', () => {
|
|||
|
||||
it('rejects a function value as not JSON-compatible', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
await expect(scope.update({ theme: () => 'dark' }))
|
||||
.rejects.toThrow(/JSON-compatible.*function at \$\.theme/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the service disposes', async () => {
|
||||
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const first = scope.update({ theme: 'light' })
|
||||
const second = scope.update({ fontSize: 20 })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
|
|
@ -503,7 +501,7 @@ describe('second review regressions', () => {
|
|||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope = child.settings.register('ui-theme', ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
|
|
@ -517,7 +515,7 @@ describe('second review regressions', () => {
|
|||
|
||||
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const patch = { fontSize: 18 }
|
||||
const pending = scope.update(patch)
|
||||
patch.fontSize = 99
|
||||
|
|
@ -530,7 +528,7 @@ describe('publish', () => {
|
|||
it('notifies watchers of an external change with source provider', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
|
|
@ -546,7 +544,7 @@ describe('publish', () => {
|
|||
it('stays silent when the resolved value is deep-equal', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
|
|
@ -557,8 +555,8 @@ describe('publish', () => {
|
|||
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
const theme = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const workspace = ctx.settings.register('workspace', NestedSchema)
|
||||
provider.pushExternal({
|
||||
'ui-theme': { fontSize: 'broken' },
|
||||
workspace: { retry: { attempts: 9 } },
|
||||
|
|
@ -570,7 +568,7 @@ describe('publish', () => {
|
|||
|
||||
it('recovers from a bad section once storage turns valid again', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
|
||||
|
|
@ -581,7 +579,7 @@ describe('publish', () => {
|
|||
describe('third review regressions', () => {
|
||||
it('skips a queued watch invocation whose disposer ran before it started', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
// The commit chains the invocation as a microtask; the disposer runs in
|
||||
|
|
@ -594,7 +592,7 @@ describe('third review regressions', () => {
|
|||
|
||||
it('waits for an in-flight watch invocation at service dispose', async () => {
|
||||
const { ctx, provider, fiber } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
let release: (() => void) | undefined
|
||||
let finished = false
|
||||
scope.watch(async () => {
|
||||
|
|
@ -614,7 +612,7 @@ describe('third review regressions', () => {
|
|||
|
||||
it('rejects a Date at its path before anything persists', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
||||
await expect(scope.update({ value: { at: new Date(0) } }))
|
||||
.rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
|
|
@ -629,13 +627,13 @@ describe('third review regressions', () => {
|
|||
['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
|
||||
])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
||||
await expect(scope.update(patch)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a circular patch instead of storing an alias-looped document', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic['self'] = cyclic
|
||||
await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
|
||||
|
|
@ -646,7 +644,7 @@ describe('third review regressions', () => {
|
|||
|
||||
it('accepts one object referenced twice without a cycle', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
|
||||
const shared = { leaf: 1 }
|
||||
await scope.update({ value: { left: shared, right: shared } })
|
||||
expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
|
||||
|
|
@ -663,7 +661,7 @@ describe('third review regressions', () => {
|
|||
ctx.on('settings/updated', boom)
|
||||
const second = vi.fn()
|
||||
ctx.on('settings/updated', second)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register('ui-theme', ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
// Containment gives the rejection a handler; vitest observes no unhandled
|
||||
|
|
@ -675,7 +673,7 @@ describe('third review regressions', () => {
|
|||
describe('watch', () => {
|
||||
it('stops after its disposer runs', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
dispose()
|
||||
|
|
@ -686,7 +684,7 @@ describe('watch', () => {
|
|||
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const scope = ctx.settings.register('ui-theme', ThemeSchema)
|
||||
scope.watch(() => { throw new Error('watcher boom') })
|
||||
const second = vi.fn()
|
||||
scope.watch(second)
|
||||
|
|
@ -699,7 +697,7 @@ describe('watch', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('installSettingsSection', () => {
|
||||
describe('SettingsProvider.installSection', () => {
|
||||
const HelperSchema: z<{ theme: string }> = z.object({
|
||||
theme: z.string().default('default'),
|
||||
})
|
||||
|
|
@ -709,13 +707,15 @@ describe('installSettingsSection', () => {
|
|||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
let changes = 0
|
||||
installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes += 1
|
||||
},
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.installSection(ctx, 'helper-ns', HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes += 1
|
||||
},
|
||||
})
|
||||
})
|
||||
// No settings service mounted: nothing ran, the entry stays authoritative.
|
||||
expect(current()).toEqual({ theme: 'entry' })
|
||||
|
|
@ -728,7 +728,7 @@ describe('installSettingsSection', () => {
|
|||
})
|
||||
expect(changes).toBe(1)
|
||||
|
||||
await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' })
|
||||
await ctx.settings.update('helper-ns', { theme: 'live' })
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
|
|
@ -749,7 +749,7 @@ describe('installSettingsSection', () => {
|
|||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
|
|
@ -782,7 +782,7 @@ describe('installSettingsSection', () => {
|
|||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
|
|
@ -817,8 +817,8 @@ describe('mutate (path-addressed writes)', () => {
|
|||
reasoning: z.string(),
|
||||
})
|
||||
|
||||
const KEYED = settingsNamespace('keyed')
|
||||
const NESTED = settingsNamespace('workspace')
|
||||
const KEYED = 'keyed'
|
||||
const NESTED = 'workspace'
|
||||
|
||||
async function mounted(doc: Record<string, unknown>) {
|
||||
const ctx = new Context()
|
||||
|
|
@ -922,7 +922,7 @@ describe('mutate (path-addressed writes)', () => {
|
|||
})
|
||||
|
||||
describe('revision and conflict detection', () => {
|
||||
const REV = settingsNamespace('rev')
|
||||
const REV = 'rev'
|
||||
const RevSchema: z<{ a: string; b: string }> = z.object({
|
||||
a: z.string().default('base-a'),
|
||||
b: z.string(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue