Skip to content

Commit 772de61

Browse files
luoling8192nekomeowww
authored andcommitted
feat(stage-ui,server): use optimistic request for provider catalog (moeru-ai#951)
--------- Co-authored-by: Neko <neko@ayaka.moe>
1 parent e60c3ee commit 772de61

File tree

7 files changed

+326
-60
lines changed

7 files changed

+326
-60
lines changed

apps/server/src/api/providers.schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const SystemProviderConfigSchema = createSelectSchema(schema.systemProvid
1010
export const InsertSystemProviderConfigSchema = createInsertSchema(schema.systemProviderConfigs)
1111

1212
export const CreateProviderConfigSchema = object({
13+
id: optional(string()),
1314
definitionId: string(),
1415
name: string(),
1516
config: optional(record(string(), string())),

packages/stage-ui/src/composables/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ export * from './llm-marker-parser'
44
export * from './markdown'
55
export * from './queues'
66
export * from './use-analytics'
7+
export * from './use-async-state'
78
export * from './use-build-info'
89
export * from './use-chat-session/summary'
10+
export * from './use-optimistic'
911
export * from './use-scroll-to-hash'
1012
export * from './use-versioned-local-storage'
1113
export * from './whisper'
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { nextTick, ref } from 'vue'
3+
4+
import { useOptimisticMutation } from './use-optimistic'
5+
6+
describe('useOptimistic', () => {
7+
it('should perform a successful optimistic update', async () => {
8+
const state = ref('initial')
9+
const actionResult = 'real-data'
10+
11+
const apply = vi.fn(() => {
12+
const old = state.value
13+
state.value = 'optimistic'
14+
return () => {
15+
state.value = old
16+
}
17+
})
18+
19+
const action = vi.fn(async () => {
20+
return actionResult
21+
})
22+
23+
const onSuccess = vi.fn((result: string) => {
24+
state.value = `final-${result}`
25+
return state.value
26+
})
27+
28+
const { state: resultState, isLoading } = useOptimisticMutation({
29+
apply,
30+
action,
31+
onSuccess,
32+
})
33+
34+
// Immediate check
35+
expect(state.value).toBe('optimistic')
36+
expect(apply).toHaveBeenCalled()
37+
38+
// Wait for action to complete
39+
await nextTick()
40+
await new Promise(resolve => setTimeout(resolve, 0))
41+
42+
expect(action).toHaveBeenCalled()
43+
expect(onSuccess).toHaveBeenCalledWith(actionResult)
44+
expect(state.value).toBe('final-real-data')
45+
expect(resultState.value).toBe('final-real-data')
46+
expect(isLoading.value).toBe(false)
47+
})
48+
49+
it('should rollback on action failure', async () => {
50+
const state = ref('initial')
51+
const error = new Error('action failed')
52+
53+
const rollback = vi.fn(() => {
54+
state.value = 'initial'
55+
})
56+
57+
const apply = vi.fn(() => {
58+
state.value = 'optimistic'
59+
return rollback
60+
})
61+
62+
const action = vi.fn(async () => {
63+
throw error
64+
})
65+
66+
const { error: errorState, isLoading } = useOptimisticMutation({
67+
apply,
68+
action,
69+
})
70+
71+
expect(state.value).toBe('optimistic')
72+
73+
// Wait for failure
74+
await nextTick()
75+
await new Promise(resolve => setTimeout(resolve, 0))
76+
77+
expect(rollback).toHaveBeenCalled()
78+
expect(state.value).toBe('initial')
79+
expect(errorState.value).toBe(error)
80+
expect(isLoading.value).toBe(false)
81+
})
82+
83+
it('should handle async apply and rollback', async () => {
84+
const state = ref('initial')
85+
86+
const apply = async () => {
87+
await new Promise(resolve => setTimeout(resolve, 10))
88+
state.value = 'optimistic'
89+
return async () => {
90+
await new Promise(resolve => setTimeout(resolve, 10))
91+
state.value = 'initial'
92+
}
93+
}
94+
95+
const action = async () => {
96+
throw new Error('fail')
97+
}
98+
99+
const { execute } = useOptimisticMutation({
100+
apply,
101+
action,
102+
})
103+
104+
await execute()
105+
106+
expect(state.value).toBe('initial')
107+
})
108+
109+
it('should not throw if apply returns non-function', async () => {
110+
const action = vi.fn(async () => {
111+
throw new Error('fail')
112+
})
113+
114+
const { execute, error } = useOptimisticMutation({
115+
// @ts-expect-error - testing invalid return
116+
apply: () => null,
117+
action,
118+
})
119+
120+
await execute()
121+
expect(error.value).toBeDefined()
122+
})
123+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useAsyncState } from './use-async-state'
2+
3+
export interface UseOptimisticMutationOptions<T, R, E = unknown> {
4+
/**
5+
* The optimistic update logic.
6+
* Should return a rollback function.
7+
*/
8+
apply: () => Promise<(() => Promise<void> | void)> | (() => Promise<void> | void)
9+
/**
10+
* The actual async task (e.g., API call).
11+
*/
12+
action: () => Promise<T>
13+
/**
14+
* Optional callback after successful action to refine state (e.g., replacing temp IDs).
15+
*/
16+
onSuccess?: (result: T) => Promise<R> | R
17+
/**
18+
* Optional callback on error. Rollback is handled automatically.
19+
*/
20+
onError?: (error?: E | null) => void | Promise<void>
21+
22+
/**
23+
* Whether to execute the action lazily.
24+
*/
25+
lazy?: boolean
26+
}
27+
28+
/**
29+
* A wrapper for performing optimistic mutations with automatic rollback.
30+
* Integrates with useAsyncState for loading/error tracking.
31+
* TODO: use https://pinia-colada.esm.dev/guide/mutations.html instead.
32+
*/
33+
export function useOptimisticMutation<T, R = T, E = unknown>(options: UseOptimisticMutationOptions<T, R, E>) {
34+
const { apply, action, onSuccess, onError, lazy = false } = options
35+
36+
return useAsyncState(async () => {
37+
const rollback = await apply()
38+
39+
try {
40+
const result = await action()
41+
if (onSuccess) {
42+
return await onSuccess(result)
43+
}
44+
return result as unknown as R
45+
}
46+
catch (err) {
47+
if (typeof rollback === 'function') {
48+
await rollback()
49+
}
50+
if (onError) {
51+
await onError(err as E)
52+
}
53+
throw err
54+
}
55+
}, { immediate: !lazy })
56+
}

packages/stage-ui/src/stores/provider-catalog.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,33 @@
11
import { createPinia, setActivePinia } from 'pinia'
2-
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
33

44
import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
55
import { useProviderCatalogStore } from './provider-catalog'
66

7+
vi.mock('../database/repos/providers.repo', () => ({
8+
providersRepo: {
9+
getAll: vi.fn(async () => ({})),
10+
saveAll: vi.fn(async () => {}),
11+
upsert: vi.fn(async () => {}),
12+
remove: vi.fn(async () => {}),
13+
},
14+
}))
15+
16+
vi.mock('../composables/api', () => ({
17+
client: {
18+
api: {
19+
providers: {
20+
'$get': vi.fn(async () => ({ ok: true, json: async () => [] })),
21+
'$post': vi.fn(async () => ({ ok: true, json: async () => ({ id: 'real-id', definitionId: 'openai-compatible', name: 'OpenAI Compatible', config: {}, validated: false, validationBypassed: false }) })),
22+
':id': {
23+
$delete: vi.fn(async () => ({ ok: true })),
24+
$patch: vi.fn(async () => ({ ok: true, json: async () => ({}) })),
25+
},
26+
},
27+
},
28+
},
29+
}))
30+
731
describe('store provider-catalog', () => {
832
beforeEach(() => {
933
// creates a fresh pinia and makes it active

0 commit comments

Comments
 (0)