From bb3128f266268c9918a35b75cca2f7b9203841d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 17 Aug 2026 17:11:03 +0800 Subject: [PATCH] refactor(ui-settings-models): welcome notice reads through the settings scope --- .../ui-settings-models/src/client/index.ts | 34 +-- .../src/client/welcome-store.ts | 166 +++++++------ .../tests/apply.client.spec.ts | 60 +++-- .../tests/welcome-notice.client.spec.tsx | 52 ++-- .../tests/welcome-store.client.spec.ts | 235 ++++++++---------- 5 files changed, 283 insertions(+), 264 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..e45e52a4e8 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -22,7 +22,7 @@ import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] /** * Register the Models section once the `settings.section` declaration is on @@ -85,10 +85,12 @@ export function apply(ctx: ClientContext): void { api: connection.api, t, }) - const welcomeController = new WelcomeNoticeStore( - connection.api, - connection.isLoopback ? 'host' : 'memory', - ) + // The scope's own memory mode is what keeps a remote browser process-local, + // so the store needs no isLoopback branch of its own. + const welcomeController = new WelcomeNoticeStore(ctx.settingsScope.bind({ + namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, + decode: decodeWelcomeSection, + })) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, hooks: { welcome: welcomeController.store }, @@ -96,23 +98,21 @@ export function apply(ctx: ClientContext): void { }) // Pushed invalidations converge every open surface without polling: any - // settings/credentials/topology change refetches once the page loaded. + // settings/credentials/topology change refetches once the page loaded. The + // welcome notice follows its settings scope, so the shared mirror already + // keeps it fresh without a subscription here. ctx.effect(() => { const refreshModels = (): void => { refreshIfLoaded(controller) } - const refreshAll = (): void => { - refreshModels() - refreshWelcomeIfLoaded(welcomeController) - } const disposers = [ - ctx.remote.$on('settings/document-updated', (ns) => { - refreshModels() - if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController) - }), + ctx.remote.$on('settings/document-updated', () => { refreshModels() }), ctx.remote.$on('credentials/updated', refreshModels), ctx.remote.$on('llm/adapters-updated', refreshModels), - ctx.on('connection/reset', refreshAll), + ctx.on('connection/reset', refreshModels), ] - return () => { for (const dispose of disposers) dispose() } + return () => { + welcomeController.dispose() + for (const dispose of disposers) dispose() + } }, 'ui-settings-models: pushed invalidations') ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 6e139f1a43..9edd54a9cb 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -1,10 +1,14 @@ -/** Welcome-notice state, durable when the browser may use Host settings. */ +/** + * Welcome-notice state derived from the welcome settings scope. The scope is + * the transport: a loopback browser follows the durable Host section, while a + * remote browser's memory-mode scope never answers and the acknowledgement + * stays process-local here. + */ -import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, } from '../onboarding-copy.ts' /** State rendered by the welcome step. */ @@ -14,113 +18,111 @@ export interface WelcomeNoticeState { error: string | null } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} +/** The welcome section as the notice reads it. */ +export type WelcomeSection = Record -function acknowledgementOf(view: SettingsNamespaceView): string | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const value = (view.value as Record)[WELCOME_NOTICE_ACK_FIELD] - return typeof value === 'string' ? value : undefined +/** + * Accept any object section verbatim; a malformed durable value reads as an + * empty section, so the notice treats it as unacknowledged instead of leaving + * the scope stuck on its previous value. + * @param section - the wire section value. + * @returns the section object, or an empty one for non-object values. + */ +export function decodeWelcomeSection(section: unknown): WelcomeSection { + return typeof section === 'object' && section !== null && !Array.isArray(section) + ? section as WelcomeSection + : {} } /** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ - status: 'idle', acknowledged: false, error: null, + status: 'idle' as const, acknowledged: false, error: null, }) - private generation = 0 + private localAcknowledged = false + private saving = false + private following: (() => void) | undefined /** - * @param api - settings wire face used for durable reads and writes. - * @param persistence - remote browsers use memory because settings is loopback-only. + * @param scope - the welcome settings namespace scope; its memory mode is + * what keeps a remote browser process-local. */ - constructor( - private readonly api: Pick, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} + constructor(private readonly scope: SettingsScope) {} - /** Load the acknowledgement from Host settings or initialize process-local state. */ + /** Begin following the bound scope (idempotent) and publish its current answer. */ async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { state.status = 'ready'; state.error = null }) - return - } - this.store.update((state) => { state.status = 'loading'; state.error = null }) - try { - const response = await this.api.settings.describe({}) - if (!response.result.ok) throw new Error(response.result.error.message) - const view = response.result.value.namespaces.find( - candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE, - ) - if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable') - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION - state.error = null - }) - } catch (error) { - if (generation !== this.generation) return - this.store.update((state) => { - state.status = 'error' - state.acknowledged = false - state.error = messageOf(error) - }) - } + this.following ??= this.scope.subscribe(() => { this.derive() }) + this.derive() } /** - * Persist this copy version, or advance only this process for a remote browser. - * @returns true when the selected persistence mode accepted the acknowledgement. + * Persist this copy version, or advance only this process for a remote + * browser. Success is judged against the state the write left behind, so a + * refused or failed write reports false after its recovery read settles. + * @returns true when the selected persistence mode holds the acknowledgement. */ async acknowledge(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) + if (this.scope.getSnapshot().mode === 'memory') { + this.localAcknowledged = true + this.derive() return true } + this.saving = true this.store.update((state) => { state.status = 'saving'; state.error = null }) try { - const response = await this.api.settings.mutate({ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + await this.scope.set(WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION) + } finally { + this.saving = false + } + this.derive() + const { acknowledged } = this.store.getSnapshot() + if (!acknowledged) { + this.store.update((state) => { + state.status = 'error' + state.error = 'the acknowledgement did not persist' }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - this.store.update((state) => { - state.status = 'ready' - state.acknowledged = true - state.error = null - }) - } - return true - } catch (error) { - if (generation === this.generation) { + } + return acknowledged + } + + /** Stop following the scope. */ + dispose(): void { + this.following?.() + this.following = undefined + } + + private derive(): void { + if (this.saving) return + const scope = this.scope.getSnapshot() + if (scope.mode === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = this.localAcknowledged + state.error = null + }) + return + } + switch (scope.status) { + case 'loading': + this.store.update((state) => { state.status = 'loading'; state.error = null }) + return + case 'unavailable': this.store.update((state) => { state.status = 'error' state.acknowledged = false - state.error = messageOf(error) + state.error = 'welcome acknowledgement settings are unavailable' + }) + return + case 'ready': { + const acknowledged = scope.value?.[WELCOME_NOTICE_ACK_FIELD] === WELCOME_NOTICE_VERSION + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = acknowledged + state.error = null }) } - return false } } } - -/** - * Refresh only after welcome state has left idle. A memory-mode load retains - * acknowledgement so reconnect does not reopen a process-local notice. - * @param controller - welcome state owner whose current status decides whether to load. - */ -export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { - if (controller.store.getSnapshot().status === 'idle') return - void controller.load() -} diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 39ba3e4b65..5267f72974 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -5,7 +5,11 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, +} from '../src/onboarding-copy.ts' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' @@ -14,7 +18,7 @@ import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench(isLoopback = true) { +async function bench(isLoopback = true, settings?: object) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -22,9 +26,10 @@ async function bench(isLoopback = true) { // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) - // The apply path only captures the wire face; no call leaves this fake - // until a section actually loads. - ctx.provide('connection', { api: {}, isLoopback } as never) + // Without a settings face the mirror's reads fail and stay contained; the + // Models join itself never fetches until a section actually loads. + ctx.provide('connection', { api: settings === undefined ? {} : { settings }, isLoopback } as never) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } } @@ -43,7 +48,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) it('registers the models nav entry for declarations before or after apply', async () => { @@ -204,8 +209,31 @@ describe('pushed invalidations', () => { expect(load).toHaveBeenCalledTimes(1) }) - it('routes only the onboarding namespace invalidation into welcome state', async () => { - const b = await bench() + it('welcome state follows the shared mirror across document commits', async () => { + // The welcome notice derives from its settings scope: a document commit + // reaches it through the mirror's one refresh, with no routing here. + const acknowledgement = { current: undefined as string | undefined } + const settings = { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'apply-welcome' as never, + result: { + ok: true as const, + value: { + writable: true, + hasDocument: false, + namespaces: [{ + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, + applies: 'live' as const, + secrets: [], + revision: 0, + }], + }, + }, + })), + } + const b = await bench(true, settings) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -214,14 +242,14 @@ describe('pushed invalidations', () => { entry.inject as unknown as () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected )() - injected.hooks.welcome.update((state) => { state.status = 'ready' }) - const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - - b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) - expect(load).not.toHaveBeenCalled() - b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2]) - expect(load).toHaveBeenCalledOnce() - b.ctx.emit('connection/reset') - expect(load).toHaveBeenCalledTimes(2) + await injected.controller.load() + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) + }) + acknowledgement.current = WELCOME_NOTICE_VERSION + b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1]) + await vi.waitFor(() => { + expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + }) }) }) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 8b8858c64a..7554e1b77f 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -2,9 +2,13 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx' -import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import type { WelcomeSection } from '../src/client/welcome-store.ts' import { en, zh } from '../src/client/locales.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -20,7 +24,24 @@ function response(value: T) { return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } } } -function mount(version?: string, mutateImpl: () => Promise = () => Promise.resolve(response({}))) { +function welcomeView(value: unknown, revision = 0) { + return { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value, + base: {}, + user: {}, + applies: 'live' as const, + secrets: [], + revision, + } +} + +function mount( + version?: string, + mutateImpl: () => Promise = () => + Promise.resolve(response(welcomeView({ [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, 1))), +) { const appRoot = document.createElement('div') appRoot.id = 'root' document.body.append(appRoot) @@ -30,21 +51,19 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom describe: () => Promise.resolve(response({ writable: true, hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, - applies: 'live' as const, - secrets: [], - revision: 0, - }], + namespaces: [welcomeView(version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version })], })), mutate, }, } - const controller = new WelcomeNoticeStore(api as never) + const mirror = new SettingsDescribeMirror(api as never) + const scope = new SettingsScopeController( + api as never, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + ) + const controller = new WelcomeNoticeStore(scope) + void mirror.load() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never const props: WelcomeNoticeProps = { @@ -57,7 +76,7 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], } - return { ...render(), complete, controller, mutate, appRoot } + return { ...render(), complete, controller, mirror, mutate, appRoot } } describe('WelcomeNotice', () => { @@ -100,7 +119,10 @@ describe('WelcomeNotice', () => { it('skips itself when this exact version was already acknowledged', async () => { const h = mount(WELCOME_NOTICE_VERSION) - await act(async () => { await h.controller.load() }) + await act(async () => { + await h.mirror.load() + await h.controller.load() + }) expect(screen.queryByRole('dialog')).toBeNull() expect(h.complete).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index e1fa7572c3..7eee8ae300 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' -import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { + SettingsDescribeMirror, SettingsScopeController, +} from '@deepseek-ai/dsh-client-ui-settings/client' +import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '../src/onboarding-copy.ts' @@ -10,31 +13,42 @@ function ok(value: T): RpcResponse { return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } } } -function namespace(version?: string) { +function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, - value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, - base: {}, - user: {}, + value, applies: 'live' as const, secrets: [], - revision: 0, + revision, } } -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } +function acknowledgedNamespace(version: string, revision = 1) { + return namespace({ [WELCOME_NOTICE_ACK_FIELD]: version }, revision) +} + +/** The welcome store over a real mirror-derived scope and a fake wire. */ +function buildWelcome( + api: { describe?: ReturnType; mutate?: ReturnType }, + persistence: 'host' | 'memory' = 'host', +) { + const wire = { settings: api } as never + const mirror = new SettingsDescribeMirror(wire, persistence) + const scope = new SettingsScopeController( + wire, + { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, + mirror, + persistence, + ) + return { mirror, controller: new WelcomeNoticeStore(scope) } } describe('WelcomeNoticeStore', () => { it('acknowledges in memory without calling loopback-only settings APIs', async () => { - const describe = vi.fn() + const describeCall = vi.fn() const mutate = vi.fn() - const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + const { controller } = buildWelcome({ describe: describeCall, mutate }, 'memory') await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) @@ -42,7 +56,7 @@ describe('WelcomeNoticeStore', () => { expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) - expect(describe).not.toHaveBeenCalled() + expect(describeCall).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() }) @@ -52,148 +66,101 @@ describe('WelcomeNoticeStore', () => { ['older-copy', false], [WELCOME_NOTICE_VERSION, true], ] as const) { - const api = { - settings: { - describe: vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(version)], - }))), - }, - } - const controller = new WelcomeNoticeStore(api as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, + hasDocument: false, + namespaces: [version === undefined ? namespace() : acknowledgedNamespace(version)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged }) } }) - it('persists the owner version through one idempotent path mutation', async () => { - const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))) - const controller = new WelcomeNoticeStore({ settings: { mutate } } as never) + it('persists the owner version through one revision-fenced mutation', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace({}, 3)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(acknowledgedNamespace(WELCOME_NOTICE_VERSION, 4)))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + await mirror.load() + await controller.load() await expect(controller.acknowledge()).resolves.toBe(true) expect(mutate).toHaveBeenCalledWith({ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }], + expectedRevision: 3, }) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + // The write answer folded into the mirror; no re-read followed. + expect(describeCall).toHaveBeenCalledTimes(1) }) - it('keeps the notice pending when loading or persistence fails', async () => { - const load = new WelcomeNoticeStore({ - settings: { describe: () => Promise.reject(new Error('offline')) }, - } as never) - await load.load() - expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' }) - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.reject(new Error('disk full')) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' }) - - const nonError = new WelcomeNoticeStore({ - // Durable/wire failures are unknown; exercise containment of a non-Error rejection. - settings: { describe: () => Promise.reject(new Error('offline string')) }, - } as never) - await nonError.load() - expect(nonError.store.getSnapshot().error).toBe('offline string') + it('keeps the notice pending while the settings read has not answered', async () => { + const describeCall = vi.fn(() => Promise.reject(new Error('offline'))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + // No answer stands, so the step renders nothing and never acknowledges. + expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null }) }) - it('reports business failures, missing namespaces, and malformed durable values', async () => { - for (const describe of [ - () => Promise.resolve({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } }, - }), - () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), - ]) { - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - await controller.load() - expect(controller.store.getSnapshot().status).toBe('error') - } + it('reports a failed or refused persistence attempt after its recovery read', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace()], + }))) + const mutate = vi.fn(() => Promise.reject(new Error('disk full'))) + const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) + await mirror.load() + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(false) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + acknowledged: false, + error: 'the acknowledgement did not persist', + }) + // The failed latest write triggered one mirror recovery read. + expect(describeCall).toHaveBeenCalledTimes(2) + }) + it('reports a missing namespace as an error instead of a silent skip', async () => { + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'error', + error: 'welcome acknowledgement settings are unavailable', + }) + }) + + it('reads malformed durable values as unacknowledged', async () => { for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) { - const controller = new WelcomeNoticeStore({ - settings: { describe: () => Promise.resolve(ok({ - writable: true, - hasDocument: false, - namespaces: [{ ...namespace(), value }], - })) }, - } as never) + const describeCall = vi.fn(() => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [namespace(value)], + }))) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) } - - const save = new WelcomeNoticeStore({ - settings: { mutate: () => Promise.resolve({ - rpcId: 'failed-save' as never, - result: { - ok: false, - error: { - code: 'settings-rejected', - message: 'denied', - details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }, - }, - }, - }) }, - } as never) - await expect(save.acknowledge()).resolves.toBe(false) - expect(save.store.getSnapshot().error).toBe('denied') }) - it('lets the latest load win over stale success and failure', async () => { - const first = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ settings: { describe } } as never) - const stale = controller.load() + it('follows a later document change without an own read', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })) + .mockResolvedValueOnce(ok({ + writable: true, hasDocument: false, + namespaces: [acknowledgedNamespace(WELCOME_NOTICE_VERSION)], + })) + const { mirror, controller } = buildWelcome({ describe: describeCall }) + await mirror.load() await controller.load() - first.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - })) - await stale - expect(controller.store.getSnapshot().acknowledged).toBe(false) - - const failed = deferred>() - describe - .mockImplementationOnce(() => failed.promise) - .mockImplementationOnce(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)], - }))) - const staleFailure = controller.load() - await controller.load() - failed.reject('stale failure') - await staleFailure - expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null }) - }) - - it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => { - const write = deferred>() - const describe = vi.fn(() => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [namespace()], - }))) - const controller = new WelcomeNoticeStore({ - settings: { mutate: () => write.promise, describe }, - } as never) - refreshWelcomeIfLoaded(controller) - expect(describe).not.toHaveBeenCalled() - const staleWrite = controller.acknowledge() - await controller.load() - write.resolve(ok(namespace(WELCOME_NOTICE_VERSION))) - await expect(staleWrite).resolves.toBe(true) - expect(controller.store.getSnapshot().acknowledged).toBe(false) - refreshWelcomeIfLoaded(controller) - await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) }) - - const failedWrite = deferred>() - const staleFailure = new WelcomeNoticeStore({ - settings: { mutate: () => failedWrite.promise, describe }, - } as never) - const pending = staleFailure.acknowledge() - await staleFailure.load() - failedWrite.reject('late failure') - await expect(pending).resolves.toBe(false) - expect(staleFailure.store.getSnapshot().status).toBe('ready') + expect(controller.store.getSnapshot()).toMatchObject({ acknowledged: false }) + await mirror.load() + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) })