浏览代码

feat(ui-settings): add SettingsDescribeMirror single describe source

Yichen Jiang 2 月之前
父节点
当前提交
29d6066870

+ 164 - 0
packages/client/ui-settings/src/client/settings-mirror.ts

@@ -0,0 +1,164 @@
+/**
+ * Client mirror of the Host settings document: the one `settings.describe`
+ * reader in the browser. Every settings consumer derives from this store —
+ * per-namespace scopes through `SettingsScopeBinder.bind`, cross-namespace
+ * surfaces through the binder's read-only describe face — so startup cost and
+ * freshness are properties of this class, not of how many features own a
+ * preference. The Host stays the fact source: the mirror re-reads on the
+ * invalidations its owning plugin subscribes to and folds write answers in
+ * through {@link SettingsDescribeMirror.acceptView}.
+ */
+
+import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+
+type SettingsFace = Pick<IApiClient, 'settings'>
+
+/** The full `settings.describe` answer the mirror serves. */
+export interface SettingsDescribeView {
+  /** Every namespace a live Host plugin registered, as the Host reported it. */
+  namespaces: readonly SettingsNamespaceView[]
+  /** Whether the settings provider accepts writes. */
+  writable: boolean
+  /** Whether a native settings document exists for the Host to open. */
+  hasDocument: boolean
+}
+
+/** Mirror state every derived settings surface renders from. */
+export interface SettingsMirrorSnapshot {
+  /**
+   * `unavailable` is the terminal non-loopback state; `ready` persists across
+   * later failed refreshes (the held view keeps serving); `idle` means no
+   * answer is held and no read is running, so `ensure` will start one.
+   */
+  status: 'idle' | 'loading' | 'ready' | 'unavailable'
+  /** The last good answer; undefined until the first success. */
+  view: SettingsDescribeView | undefined
+  /** The latest refresh failure message, cleared by the next success. */
+  error: string | null
+}
+
+/**
+ * Serializes every Host `settings.describe` read behind one snapshot store.
+ * Concurrent {@link load} calls fold into the in-flight read plus one rerun,
+ * so an invalidation arriving mid-read is never lost and never duplicated.
+ */
+export class SettingsDescribeMirror {
+  private readonly store: SnapshotStore<SettingsMirrorSnapshot>
+  private inFlight: Promise<void> | undefined
+  private rerun = false
+  private generation = 0
+
+  /**
+   * @param api - settings wire face.
+   * @param persistence - remote browsers stay process-local because settings RPCs are loopback-only.
+   */
+  constructor(
+    private readonly api: SettingsFace,
+    private readonly persistence: 'host' | 'memory' = 'host',
+  ) {
+    this.store = createSnapshotStore<SettingsMirrorSnapshot>({
+      status: persistence === 'host' ? 'idle' : 'unavailable',
+      view: undefined,
+      error: null,
+    })
+  }
+
+  /** @returns the current sync snapshot (stable reference until the next change). */
+  getSnapshot(): SettingsMirrorSnapshot {
+    return this.store.getSnapshot()
+  }
+
+  /**
+   * Observe snapshot replacements.
+   * @param listener - invoked after each snapshot change.
+   * @returns the disposer removing this listener.
+   */
+  subscribe(listener: () => void): () => void {
+    return this.store.subscribe(listener)
+  }
+
+  /**
+   * Refresh from the Host. A call during an in-flight read marks one rerun
+   * after it settles instead of racing a second wire read.
+   * @returns settlement after this call's freshness is reflected.
+   */
+  load(): Promise<void> {
+    if (this.persistence === 'memory') return Promise.resolve()
+    if (this.inFlight !== undefined) {
+      this.rerun = true
+      return this.inFlight
+    }
+    const run = this.run().finally(() => { this.inFlight = undefined })
+    this.inFlight = run
+    return run
+  }
+
+  /**
+   * Resolve once an answer is held (or the mirror is terminally unavailable),
+   * reading only from `idle`. The cheap idempotent entry for surfaces that
+   * render on first use.
+   * @returns settlement of the current or newly started read, if any.
+   */
+  ensure(): Promise<void> {
+    if (this.persistence === 'memory') return Promise.resolve()
+    if (this.inFlight !== undefined) return this.inFlight
+    if (this.getSnapshot().status === 'idle') return this.load()
+    return Promise.resolve()
+  }
+
+  /**
+   * Fold one write answer's namespace view into the held view without a wire
+   * read. A no-op until a first answer exists — a write cannot precede the
+   * read that supplied its `expectedRevision`.
+   * @param view - the namespace view a settings write answered with.
+   */
+  acceptView(view: SettingsNamespaceView): void {
+    const before = this.store.getSnapshot()
+    if (before.view === undefined) return
+    const namespaces = before.view.namespaces.some(row => row.ns === view.ns)
+      ? before.view.namespaces.map(row => row.ns === view.ns ? view : row)
+      : [...before.view.namespaces, view]
+    this.store.set({ ...before, view: { ...before.view, namespaces } })
+  }
+
+  /**
+   * Convenience row lookup on the held view.
+   * @param ns - namespace identity.
+   * @returns the namespace view, or undefined while unanswered or unregistered.
+   */
+  namespace(ns: string): SettingsNamespaceView | undefined {
+    return this.store.getSnapshot().view?.namespaces.find(row => row.ns === ns)
+  }
+
+  private async run(): Promise<void> {
+    do {
+      this.rerun = false
+      const generation = ++this.generation
+      const before = this.store.getSnapshot()
+      if (before.status === 'idle') this.store.set({ ...before, status: 'loading' })
+      let outcome: { view: SettingsDescribeView } | { failure: string }
+      try {
+        const response = await this.api.settings.describe({})
+        outcome = response.result.ok
+          ? { view: response.result.value }
+          : { failure: response.result.error.message }
+      } catch (error) {
+        outcome = { failure: error instanceof Error ? error.message : String(error) }
+      }
+      if (generation !== this.generation) continue
+      if ('view' in outcome) {
+        this.store.set({ status: 'ready', view: outcome.view, error: null })
+      } else {
+        const held = this.store.getSnapshot()
+        // No answer yet: fall back to idle so `ensure` retries; with one, the
+        // held view keeps serving and only the error field reports the miss.
+        this.store.set({
+          status: held.view === undefined ? 'idle' : 'ready',
+          view: held.view,
+          error: outcome.failure,
+        })
+      }
+    } while (this.rerun)
+  }
+}

+ 140 - 0
packages/client/ui-settings/tests/settings-mirror.client.spec.ts

@@ -0,0 +1,140 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
+import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts'
+
+let rpc = 0
+
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: `mirror-${rpc++}` as never, result: { ok: true, value } }
+}
+
+function rejected<T>(message: string): RpcResponse<T> {
+  return {
+    rpcId: `mirror-${rpc++}` as never,
+    result: {
+      ok: false,
+      error: { code: 'settings-rejected', message, details: {} },
+    },
+  }
+}
+
+function view(ns: string, revision = 0): SettingsNamespaceView {
+  return { ns, schema: {}, value: { field: ns }, applies: 'live', secrets: [], revision }
+}
+
+function described(namespaces: SettingsNamespaceView[]): RpcResponse<SettingsDescribeView> {
+  return ok({ writable: true, hasDocument: true, namespaces })
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((res) => { resolve = res })
+  return { promise, resolve }
+}
+
+describe('SettingsDescribeMirror', () => {
+  it('folds concurrent load calls into the in-flight read plus one rerun', async () => {
+    const gate = deferred<RpcResponse<SettingsDescribeView>>()
+    const describeCall = vi.fn()
+      .mockReturnValueOnce(gate.promise)
+      .mockResolvedValue(described([view('theme', 1)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    const first = mirror.load()
+    const second = mirror.load()
+    const third = mirror.load()
+    gate.resolve(described([view('theme', 0)]))
+    await Promise.all([first, second, third])
+    expect(describeCall).toHaveBeenCalledTimes(2)
+    expect(mirror.getSnapshot().status).toBe('ready')
+    expect(mirror.namespace('theme')?.revision).toBe(1)
+  })
+
+  it('keeps the last good view when a later refresh fails, recording the failure', async () => {
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(described([view('theme', 2)]))
+      .mockRejectedValueOnce(new Error('host gone'))
+      .mockResolvedValueOnce(rejected('busy'))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    await mirror.load()
+    expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
+    await mirror.load()
+    expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'host gone' })
+    expect(mirror.namespace('theme')?.revision).toBe(2)
+    await mirror.load()
+    expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: 'busy' })
+    expect(mirror.getSnapshot().view?.namespaces).toHaveLength(1)
+  })
+
+  it('returns to idle after a first read that never succeeded, so ensure retries', async () => {
+    const describeCall = vi.fn()
+      .mockRejectedValueOnce(new Error('offline'))
+      .mockResolvedValueOnce(described([view('theme', 1)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    await mirror.ensure()
+    expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' })
+    await mirror.ensure()
+    expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null })
+    expect(describeCall).toHaveBeenCalledTimes(2)
+  })
+
+  it('treats ensure as a no-op once ready', async () => {
+    const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    await mirror.ensure()
+    await mirror.ensure()
+    await mirror.ensure()
+    expect(describeCall).toHaveBeenCalledTimes(1)
+  })
+
+  it('memory persistence is terminally unavailable and never touches the wire', async () => {
+    const describeCall = vi.fn()
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory')
+    await mirror.ensure()
+    await mirror.load()
+    expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null })
+    expect(describeCall).not.toHaveBeenCalled()
+  })
+
+  it('acceptView folds one write answer into the held view without a wire read', async () => {
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    await mirror.load()
+    const seen: number[] = []
+    mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) })
+    mirror.acceptView(view('theme', 9))
+    expect(mirror.namespace('theme')?.revision).toBe(9)
+    expect(mirror.namespace('locale')?.revision).toBe(4)
+    expect(seen).toEqual([9])
+    expect(describeCall).toHaveBeenCalledTimes(1)
+  })
+
+  it('acceptView before any answer is a no-op instead of inventing a document', () => {
+    const describeCall = vi.fn()
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    mirror.acceptView(view('theme', 1))
+    expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null })
+  })
+
+  it('acceptView appends a namespace the held view has not seen yet', async () => {
+    const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    await mirror.load()
+    mirror.acceptView(view('fresh-ns', 0))
+    expect(mirror.namespace('fresh-ns')).toBeDefined()
+    expect(mirror.getSnapshot().view?.namespaces).toHaveLength(2)
+  })
+
+  it('suppresses a stale answer that lost to a newer generation', async () => {
+    const slow = deferred<RpcResponse<SettingsDescribeView>>()
+    const describeCall = vi.fn()
+      .mockReturnValueOnce(slow.promise)
+      .mockResolvedValue(described([view('theme', 8)]))
+    const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never)
+    const first = mirror.load()
+    const second = mirror.load()
+    slow.resolve(described([view('theme', 1)]))
+    await Promise.all([first, second])
+    expect(mirror.namespace('theme')?.revision).toBe(8)
+  })
+})