feat(mutate): single funnel for user-intent commands and invoke gate

Adds src/services/mutate.ts: one path for optimistic apply, services-layer
invoke, Busy handling, failure rollback, and the error toast, with busy
bookkeeping mirrored into the app store. The patch hooks (verge, clash,
profiles) now route through it. Moves the remaining direct invokes
(unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services
architecture gate in CI.
This commit is contained in:
Tunglies
2026-10-01 14:34:17 +08:00
committed by GitHub
parent 5b53c32de1
commit 763338dade
10 changed files with 204 additions and 48 deletions
+2 -2
View File
@@ -86,9 +86,9 @@ jobs:
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus)
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-22.04
+10 -11
View File
@@ -13,7 +13,6 @@ import {
TextField,
Typography,
} from '@mui/material'
import { invoke } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks'
import * as yaml from 'js-yaml'
import type { Ref } from 'react'
@@ -36,6 +35,12 @@ import {
import { useClash } from '@/hooks/use-clash'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import {
applyDnsConfig,
getDnsConfigContent,
saveDnsConfig,
validateDnsConfig,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco'
@@ -465,10 +470,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const initDnsConfig = useCallback(async () => {
try {
const dnsConfig = await invoke<string | null>(
'get_dns_config_content',
{},
)
const dnsConfig = await getDnsConfigContent()
if (dnsConfig !== null) {
const config = yaml.load(dnsConfig) as any
@@ -517,12 +519,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config = parsedConfig as Record<string, any>
}
await invoke('save_dns_config', { dnsConfig: config })
await saveDnsConfig(config)
const validation = await invoke<ValidationOutcome>(
'validate_dns_config',
{},
)
const validation = await validateDnsConfig()
if (validation.status !== 'valid') {
const errorMsg =
@@ -559,7 +558,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
if (dnsEnabled) {
await invoke('apply_dns_config', { apply: true })
await applyDnsConfig(true)
mutateClash()
}
+9 -5
View File
@@ -8,6 +8,7 @@ import {
getRuntimeConfig,
patchClashConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
revalidateQuery,
setCacheData,
@@ -104,8 +105,10 @@ export const useClash = () => {
}
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
await patchClashConfig(patch)
mutateClash()
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-config',
revalidate: [['getRuntimeConfig']],
})
})
const version = versionData?.meta
@@ -132,9 +135,10 @@ export const useClashInfo = () => {
validatePorts(patch)
await patchClashConfig(patch)
mutateInfo()
revalidateQuery(['getClashConfig'])
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-info',
revalidate: [['getClashInfo'], ['getClashConfig']],
})
})
const invalidateClashConfig = () => revalidateQuery(['getClashConfig'])
+17 -7
View File
@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
@@ -42,16 +43,25 @@ export const useProfiles = () => {
const patchProfiles = useCallback(
async (value: Partial<IProfilesConfig>) => {
try {
const outcome = await patchProfilesConfig(value)
const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config',
onFulfilled: (outcome) => {
if (outcome.status === 'valid') {
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
}
},
})
if (outcome.status === 'valid') {
await setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
} else if (outcome.status !== 'busy') {
if (!result.ok) {
// Backend Busy keeps local state untouched, as before.
return result.value ?? { status: 'busy' }
}
const outcome = result.value
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles()
}
return outcome
} catch (error) {
await mutateProfiles()
+7 -7
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -38,13 +39,12 @@ export const useVerge = () => {
)
}
const patchVerge = useCallback(
async (value: Partial<IVergeConfig>) => {
await patchVergeConfig(value)
await refetch()
},
[refetch],
)
const patchVerge = useCallback(async (value: Partial<IVergeConfig>) => {
await mutate(() => patchVergeConfig(value), {
id: 'patch-verge-config',
revalidate: [['getVergeConfig']],
})
}, [])
return {
verge,
+10 -15
View File
@@ -19,21 +19,20 @@ import {
alpha,
useTheme,
} from '@mui/material'
import { Channel, invoke } from '@tauri-apps/api/core'
import { Channel } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseEmpty, BasePage } from '@/components/base'
import {
checkMediaUnlock,
checkMediaUnlockItem,
getUnlockItems,
type UnlockItem,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
interface UnlockItem {
name: string
status: string
region?: string | null
check_time?: string | null
}
const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results'
const STATUS_LABEL_KEYS: Record<string, string> = {
@@ -119,7 +118,7 @@ const UnlockPage = () => {
void (async () => {
try {
const defaultItems = await invoke<UnlockItem[]>('get_unlock_items')
const defaultItems = await getUnlockItems()
const existingMap = new Map(
storedItems.map((item) => [normalizeUnlockName(item.name), item]),
)
@@ -153,9 +152,7 @@ const UnlockPage = () => {
try {
setIsCheckingAll(true)
setLoadingItems(unlockItems.map((item) => item.name))
const result = await invoke<UnlockItem[]>('check_media_unlock', {
onComplete,
})
const result = await checkMediaUnlock(onComplete)
const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name))
unlockItemsRef.current = sortedItems
@@ -174,9 +171,7 @@ const UnlockPage = () => {
const checkSingleMedia = async (name: string) => {
setLoadingItems((items) => [...items, name])
try {
const result = await invoke<UnlockItem>('check_media_unlock_item', {
name,
})
const result = await checkMediaUnlockItem(name)
const updatedItems = unlockItemsRef.current.map((item) =>
item.name === name ? result : item,
)
+36 -1
View File
@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core'
import { Channel, invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import type { CommandFailure } from '@/services/notice-service'
@@ -182,6 +182,22 @@ export async function setDnsOverride(
>('set_dns_override', { profileUid, enabled, confirmation })
}
export async function getDnsConfigContent() {
return invoke<string | null>('get_dns_config_content')
}
export async function saveDnsConfig(dnsConfig: Record<string, unknown>) {
return invoke<void>('save_dns_config', { dnsConfig })
}
export async function validateDnsConfig() {
return invoke<ValidationOutcome>('validate_dns_config')
}
export async function applyDnsConfig(apply: boolean) {
return invoke<void>('apply_dns_config', { apply })
}
export async function takeDnsOverrideNotice() {
return invoke<boolean>('take_dns_override_notice')
}
@@ -540,3 +556,22 @@ export const probeListener = async (request: ListenerProbe) => {
export const saveProxyPorts = async (settings: ProxyPortSettings) => {
return invoke<SaveProxyPortsOutcome>('save_proxy_ports', { settings })
}
export interface UnlockItem {
name: string
status: string
region?: string | null
check_time?: string | null
}
export async function getUnlockItems() {
return invoke<UnlockItem[]>('get_unlock_items')
}
export async function checkMediaUnlock(onComplete: Channel<UnlockItem>) {
return invoke<UnlockItem[]>('check_media_unlock', { onComplete })
}
export async function checkMediaUnlockItem(name: string) {
return invoke<UnlockItem>('check_media_unlock_item', { name })
}
+90
View File
@@ -0,0 +1,90 @@
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
import type { AppStoreAction } from '@/store/app-state'
/** Anything a notice call accepts: translation key, ReactNode, or failure payload. */
type MutateNoticeInput = Parameters<typeof showNotice.error>[0]
export interface MutateOptions<T> {
/** Busy/in-flight identity; a second intent with the same id resolves `{ busy: true }`. */
id: string
/** Applied before the command runs; undone by `rollback` on failure. */
optimistic?: () => void
/** Restores pre-optimistic state when the command rejects. */
rollback?: () => void | Promise<void>
/** Runs after a fulfilled (non-busy) result, e.g. cache merges. */
onFulfilled?: (value: T) => void
/** SWR keys revalidated after a fulfilled result. */
revalidate?: readonly (readonly unknown[] | string)[]
/** Shown after a fulfilled result (most flows leave this to backend notices). */
successNotice?: MutateNoticeInput
/** Shown when the command resolves with `{ status: 'busy' }`. */
busyNotice?: MutateNoticeInput
/** `false` suppresses the default error toast. */
errorNotice?: false | MutateNoticeInput
}
export type MutateResult<T> =
| { ok: true; value: T }
| { ok: false; busy: true; value?: T }
/** ValidationOutcome-shaped results report backend-side Busy rejection this way. */
const isBusyOutcome = (value: unknown): boolean =>
typeof value === 'object' &&
value !== null &&
(value as { status?: unknown }).status === 'busy'
const inFlight = new Set<string>()
let storeDispatch: ((action: AppStoreAction) => void) | null = null
/** The app store registers its dispatcher so the funnel can record busy state. */
export const bindStoreDispatch = (
dispatch: ((action: AppStoreAction) => void) | null,
) => {
storeDispatch = dispatch
}
/**
* The single funnel for "user intent → backend command": optimistic apply,
* invoke through the services layer, Busy handling, failure rollback, and the
* error toast, in that order, for every mutating call site.
*/
export async function mutate<T>(
invoke: () => Promise<T>,
options: MutateOptions<T>,
): Promise<MutateResult<T>> {
const { id } = options
if (inFlight.has(id)) return { ok: false, busy: true }
inFlight.add(id)
storeDispatch?.({ type: 'busy/started', id })
options.optimistic?.()
try {
const value = await invoke()
if (isBusyOutcome(value)) {
if (options.busyNotice !== undefined) {
showNotice.warning(options.busyNotice)
}
return { ok: false, busy: true, value }
}
options.onFulfilled?.(value)
if (options.revalidate?.length) {
await revalidateQueries([...options.revalidate])
}
if (options.successNotice !== undefined) {
showNotice.success(options.successNotice)
}
return { ok: true, value }
} catch (error) {
await options.rollback?.()
if (options.errorNotice !== false) {
showNotice.error(
options.errorNotice !== undefined ? options.errorNotice : error,
)
}
throw error
} finally {
inFlight.delete(id)
storeDispatch?.({ type: 'busy/settled', id })
}
}
+17
View File
@@ -15,6 +15,8 @@ export interface AppStoreState {
readonly profileUpdates: ProfileUpdatesState
/** Bumped once per test-all request; test items run their delay test per bump. */
readonly testAllCounter: number
/** Mutate-funnel bookkeeping: in-flight intent ids. */
readonly busy: ReadonlySet<string>
}
export const initialAppStoreState: AppStoreState = {
@@ -26,6 +28,7 @@ export const initialAppStoreState: AppStoreState = {
timerRevisions: new Map(),
},
testAllCounter: 0,
busy: new Set(),
}
export type AppStoreAction =
@@ -36,6 +39,8 @@ export type AppStoreAction =
| { type: 'profileUpdate/timerTick'; uid: string }
| { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean }
| { type: 'testAll/requested' }
| { type: 'busy/started'; id: string }
| { type: 'busy/settled'; id: string }
const bumpRevision = (
map: ReadonlyMap<string, number>,
@@ -105,5 +110,17 @@ export function appStoreReducer(
return withLoading(state, action.uids, action.loading)
case 'testAll/requested':
return { ...state, testAllCounter: state.testAllCounter + 1 }
case 'busy/started': {
if (state.busy.has(action.id)) return state
const busy = new Set(state.busy)
busy.add(action.id)
return { ...state, busy }
}
case 'busy/settled': {
if (!state.busy.has(action.id)) return state
const busy = new Set(state.busy)
busy.delete(action.id)
return { ...state, busy }
}
}
}
+6
View File
@@ -2,6 +2,7 @@ import { useEffect, useReducer } from 'react'
import type { ReactNode } from 'react'
import { getPendingFailures, getRuntimeState } from '@/services/cmds'
import { bindStoreDispatch } from '@/services/mutate'
import { appStoreReducer, initialAppStoreState } from './app-state'
import { AppDispatchContext, AppStateContext } from './app-store-context'
@@ -15,6 +16,11 @@ import { AppDispatchContext, AppStateContext } from './app-store-context'
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
useEffect(() => {
bindStoreDispatch(dispatch)
return () => bindStoreDispatch(null)
}, [])
useEffect(() => {
const readRunState = () => {
getRuntimeState()