From f4e49ccf8f8c7b166c919a2e4f848cbe225eff9e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:29:51 +0800 Subject: [PATCH] refactor(services): move shared values behind service APIs --- packages/api/remotes/src/client/index.ts | 4 - packages/api/remotes/src/index.ts | 3 +- packages/api/remotes/src/remote-events.ts | 8 +- packages/api/remotes/tsconfig.host.json | 3 + .../api/session-controller/src/commands.ts | 8 +- .../api/session-controller/src/control.ts | 3 +- .../session-controller/src/remote-events.ts | 19 +- packages/api/session-controller/src/types.ts | 3 +- packages/api/settings-controller/src/index.ts | 43 ++-- .../tests/settings-controller.host.spec.ts | 9 +- packages/core/system-prompt/src/index.ts | 52 +++-- .../system-prompt/tests/system-prompt.spec.ts | 29 ++- packages/settings/settings/src/index.ts | 206 +++++++++--------- packages/settings/settings/src/invariant.ts | 2 +- packages/settings/settings/src/types.ts | 2 +- .../settings/settings/tests/invariant.spec.ts | 14 +- .../settings/settings/tests/redact.spec.ts | 4 +- .../settings/settings/tests/settings.spec.ts | 152 ++++++------- packages/settings/settings/tsconfig.json | 2 +- 19 files changed, 307 insertions(+), 259 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 43821e10b9..e7fc7757e9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -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 diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index b37136079d..5a11bae49d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -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` diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index bf98fe536c..815a7b66a0 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -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' }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index d344c6be3d..65fca35ee0 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -45,6 +45,9 @@ { "path": "../../util/deque" }, + { + "path": "../../util/values" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 3abefd52fa..1ad9127925 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -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(`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(`session-${randomUUID()}`) const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)) try { const { provider, model } = this.ctx.agentDefaultModel.currentSelection() diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index db79f0a594..1a03011644 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -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, diff --git a/packages/api/session-controller/src/remote-events.ts b/packages/api/session-controller/src/remote-events.ts index 94d194d72a..f9112e9e1f 100644 --- a/packages/api/session-controller/src/remote-events.ts +++ b/packages/api/session-controller/src/remote-events.ts @@ -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 {} + Record {} } + +export {} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 7359d89004..60e5ca35ba 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -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' { diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index 19056ef1dd..c5e9037ab3 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -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 }, ) } diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index d9b0481cdd..c5df993b59 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -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 { - return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`)) + override mutate(): Promise { + return Promise.reject(new Error('settings are read-only in this deployment')) } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index f0f0715971..fba1b51f50 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -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 } -/** - * 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. diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 07c5408f24..9818c50f94 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -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() diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 4150aa9a81..232364b4e8 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -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 '' + ? true + : Value extends `${NamespaceCharacter}${infer Rest}` + ? ValidNamespaceTail + : false +type SettingsNamespaceInput = Value extends SettingsNamespace + ? Value + : string extends Value + ? string + : Value extends `${LowercaseLetter}${infer Rest}` + ? ValidNamespaceTail 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 - const right = b as Record - 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(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 @@ -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(ns: SettingsNamespace, schema: z, options?: SettingsRegisterOptions): SettingsScope { - if (this.registrations.has(ns)) { - throw new Error(`settings namespace "${ns}" is already registered`) + register( + ns: Namespace & SettingsNamespaceInput, + schema: z, + options?: SettingsRegisterOptions, + ): SettingsScope { + 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, 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( + owner: Context, + ns: Namespace & SettingsNamespaceInput, + schema: z, + entry: T, + hooks: SettingsSectionHooks, + ): void { + const scope = this.register(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(ns: Namespace & SettingsNamespaceInput): 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 { - return this.write(ns, patch, 'merge', expectedRevision) + async update( + ns: Namespace & SettingsNamespaceInput, + patch: object, + expectedRevision?: number, + ): Promise { + 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 { - return this.write(ns, section, 'replace', expectedRevision) + async replace( + ns: Namespace & SettingsNamespaceInput, + section: object, + expectedRevision?: number, + ): Promise { + 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 { - if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`) + async mutate( + ns: Namespace & SettingsNamespaceInput, + ops: readonly SettingsPathOp[], + expectedRevision?: number, + ): Promise { + 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 { /** * Receive the active configuration source: the resolved settings scope @@ -847,53 +890,4 @@ export interface SettingsSectionHooks { 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( - ctx: Context, - ns: SettingsNamespace, - schema: z, - entry: T, - hooks: SettingsSectionHooks, -): 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 diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts index d0e0344b19..3056671a44 100644 --- a/packages/settings/settings/src/invariant.ts +++ b/packages/settings/settings/src/invariant.ts @@ -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' diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts index 5c00b877eb..8068d4ef13 100644 --- a/packages/settings/settings/src/types.ts +++ b/packages/settings/settings/src/types.ts @@ -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'> diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts index db3c0f0f02..31de9df12a 100644 --- a/packages/settings/settings/tests/invariant.spec.ts +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -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 { @@ -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/) }) }) diff --git a/packages/settings/settings/tests/redact.spec.ts b/packages/settings/settings/tests/redact.spec.ts index dbf02e8ba8..dc8530058f 100644 --- a/packages/settings/settings/tests/redact.spec.ts +++ b/packages/settings/settings/tests/redact.spec.ts @@ -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) { const ctx = new Context() diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 804f57be0d..be86c6b6ac 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -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 = {} 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) { 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(), diff --git a/packages/settings/settings/tsconfig.json b/packages/settings/settings/tsconfig.json index 60ca039d1c..c06688eecf 100644 --- a/packages/settings/settings/tsconfig.json +++ b/packages/settings/settings/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../util/brand" }, { - "path": "../../core/session" + "path": "../../util/values" }, { "path": "../../runtime-diagnostics/invariants"