Преглед изворни кода

feat(client-runtime): carry the layered view and a field reset through the settings scope

A form needs two things the snapshot did not carry. The `user` layer tells it
which fields the user overrode — presence, not value equality, because an
override equal to the composition default is still an override — and `base`
is what a cleared field reverts to. `unset` is that clear, sharing `set`'s
queue, revision fence, and rejected-write recovery through one write path.
Yichen Jiang пре 1 месец
родитељ
комит
8a3c5daad7

+ 2 - 2
packages/client/runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412
-README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8
+README.md: b605fd2adc13ab6a1a4b727d116fc4e8b9973b10
+README.zh.md: efd86b9d5deb21bdec298d305c1fef0e687ce6f4

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/runtime/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/runtime/README.zh.md


+ 38 - 2
packages/client/runtime/src/client/settings-scope.ts

@@ -2,7 +2,7 @@
 
 import type { Context } from 'cordis'
 import type {
-  ConnectionHandle, IApiClient, SettingsNamespaceView,
+  ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView,
 } from '@deepseek-ai/dsh-client-connection/client'
 import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
 import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
@@ -17,6 +17,17 @@ export interface SettingsScopeSnapshot<T> {
   status: 'loading' | 'ready' | 'unavailable'
   /** Last accepted schema-resolved section; undefined before the first acceptance. */
   value: T | undefined
+  /**
+   * Composition layer the Host resolved {@link value} over, when the owning
+   * plugin declared one. What a field reverts to once cleared.
+   */
+  base: unknown
+  /**
+   * Raw user layer as stored, when one exists. A field's PRESENCE here is what
+   * marks it overridden — an override whose value equals the composition
+   * default is still an override, and comparing values could not see it.
+   */
+  user: unknown
   /** Namespace revision fencing the next write; undefined before the first Host view. */
   revision: number | undefined
   /** Whether the Host document accepts writes; memory mode never does. */
@@ -60,6 +71,13 @@ export interface SettingsScope<T> {
    * @returns settlement after the write and any latest-write recovery read.
    */
   set(field: string, value: unknown): Promise<void>
+  /**
+   * Queue one field clear, so the field re-inherits the composition layer.
+   * Shares {@link set}'s ordering, revision, and recovery contract.
+   * @param field - scalar field inside the namespace section.
+   * @returns settlement after the clear and any latest-write recovery read.
+   */
+  unset(field: string): Promise<void>
 }
 
 type SettingsFace = Pick<IApiClient, 'settings'>
@@ -90,6 +108,8 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
     this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
       status: persistence === 'host' ? 'loading' : 'unavailable',
       value: undefined,
+      base: undefined,
+      user: undefined,
       revision: undefined,
       writable: false,
       mode: persistence,
@@ -127,6 +147,20 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
    * @returns settlement after the write and any latest-write recovery read.
    */
   set(field: string, value: unknown): Promise<void> {
+    return this.write({ op: 'set', path: [field], value })
+  }
+
+  /**
+   * Queue one field clear; see {@link SettingsScope.unset} for the ordering,
+   * revision, and recovery contract.
+   * @param field - scalar field inside the namespace section.
+   * @returns settlement after the clear and any latest-write recovery read.
+   */
+  unset(field: string): Promise<void> {
+    return this.write({ op: 'unset', path: [field] })
+  }
+
+  private write(op: SettingsPathOpView): Promise<void> {
     this.readGeneration += 1
     const generation = ++this.writeGeneration
     return this.enqueue(async () => {
@@ -135,7 +169,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
       try {
         response = await this.api.settings.mutate({
           ns: this.spec.namespace,
-          ops: [{ op: 'set', path: [field], value }],
+          ops: [op],
           ...(revision === undefined ? {} : { expectedRevision: revision }),
         })
       } catch (_settingsWriteFailure) {
@@ -200,6 +234,8 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
     const decoded = publish ? this.decode(view) : undefined
     this.store.update((draft) => {
       draft.revision = view.revision
+      draft.base = view.base
+      draft.user = view.user
       if (writable !== undefined) draft.writable = writable
       if (decoded === undefined) return
       draft.status = 'ready'

+ 71 - 0
packages/client/runtime/tests/settings-scope.spec.ts

@@ -293,6 +293,77 @@ describe('SettingsScopeController', () => {
     expect(describeCall).not.toHaveBeenCalled()
     expect(mutate).not.toHaveBeenCalled()
   })
+
+  it('carries the composition base and the user layer into the snapshot', async () => {
+    const layered: SettingsNamespaceView = {
+      ...view({ preference: 'dark' }, 3),
+      base: { preference: 'system' },
+      user: { preference: 'dark' },
+    }
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] }))
+    const scope = new SettingsScopeController<UiTestSettings>(
+      { settings: { describe: describeCall } } as never,
+      { namespace: 'ui-test' },
+    )
+
+    await scope.load()
+
+    expect(scope.getSnapshot()).toMatchObject({
+      value: { preference: 'dark' },
+      base: { preference: 'system' },
+      user: { preference: 'dark' },
+    })
+  })
+
+  it('reports an inherited field as absent from the user layer', async () => {
+    const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } }
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] }))
+    const scope = new SettingsScopeController<UiTestSettings>(
+      { settings: { describe: describeCall } } as never,
+      { namespace: 'ui-test' },
+    )
+
+    await scope.load()
+
+    expect(scope.getSnapshot().user).toBeUndefined()
+  })
+
+  it('clears one field through an unset op fenced by the held revision', async () => {
+    const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4)))
+    const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
+    const scope = new SettingsScopeController<UiTestSettings>(
+      { settings: { describe: describeCall, mutate } } as never,
+      { namespace: 'ui-test' },
+    )
+    await scope.load()
+
+    await scope.unset('preference')
+
+    expect(mutate).toHaveBeenCalledWith({
+      ns: 'ui-test',
+      ops: [{ op: 'unset', path: ['preference'] }],
+      expectedRevision: 3,
+    })
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 })
+  })
+
+  it('recovers the Host state when the latest clear is refused', async () => {
+    const mutate = vi.fn().mockResolvedValueOnce(rejected())
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(described({ preference: 'dark' }, 3))
+      .mockResolvedValueOnce(described({ preference: 'light' }, 5))
+    const scope = new SettingsScopeController<UiTestSettings>(
+      { settings: { describe: describeCall, mutate } } as never,
+      { namespace: 'ui-test' },
+    )
+    await scope.load()
+
+    await scope.unset('preference')
+
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 })
+  })
 })
 
 describe('bindSettingsScope', () => {

+ 7 - 1
packages/client/test-runtime/src/settings-scope.ts

@@ -8,6 +8,8 @@ export interface StubSettingsScope<T> {
   scope: SettingsScope<T>
   /** Spy behind `scope.set`; resolves immediately. */
   set: ReturnType<typeof vi.fn>
+  /** Spy behind `scope.unset`; resolves immediately. */
+  unset: ReturnType<typeof vi.fn>
   /** @returns how many listeners are currently subscribed (disposal assertions). */
   listenerCount(): number
   /**
@@ -25,10 +27,12 @@ export interface StubSettingsScope<T> {
  */
 export function stubSettingsScope<T>(): StubSettingsScope<T> {
   let snapshot: SettingsScopeSnapshot<T> = {
-    status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
+    status: 'loading', value: undefined, base: undefined, user: undefined,
+    revision: undefined, writable: false, mode: 'host',
   }
   const listeners = new Set<() => void>()
   const set = vi.fn(() => Promise.resolve())
+  const unset = vi.fn(() => Promise.resolve())
   return {
     scope: {
       getSnapshot: () => snapshot,
@@ -37,8 +41,10 @@ export function stubSettingsScope<T>(): StubSettingsScope<T> {
         return () => { listeners.delete(listener) }
       },
       set,
+      unset,
     },
     set,
+    unset,
     listenerCount: () => listeners.size,
     publish: (next) => {
       snapshot = { ...snapshot, ...next }

+ 30 - 0
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -7,6 +7,7 @@
  * stack — this suite is the fixture the migrated feature specs rely on.
  */
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubSettingsScope } from '../src/settings-scope.ts'
 import { cleanup } from '@testing-library/react'
 import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -635,3 +636,32 @@ describe('single-slot mounting edge arms', () => {
     await runtime.dispose()
   })
 })
+
+describe('stubbed settings scope', () => {
+  it('records both write kinds and publishes a Host acceptance to its listeners', async () => {
+    const host = stubSettingsScope<{ preference: string }>()
+    let notified = 0
+    const stop = host.scope.subscribe(() => { notified += 1 })
+    expect(host.listenerCount()).toBe(1)
+    expect(host.scope.getSnapshot()).toMatchObject({
+      status: 'loading', base: undefined, user: undefined,
+    })
+
+    await host.scope.set('preference', 'dark')
+    await host.scope.unset('preference')
+    host.publish({
+      status: 'ready',
+      value: { preference: 'system' },
+      base: { preference: 'system' },
+      revision: 2,
+      writable: true,
+    })
+
+    expect(host.set).toHaveBeenCalledWith('preference', 'dark')
+    expect(host.unset).toHaveBeenCalledWith('preference')
+    expect(notified).toBe(1)
+    expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true })
+    stop()
+    expect(host.listenerCount()).toBe(0)
+  })
+})

Неке датотеке нису приказане због велике количине промена