Add feature flipping mechanism
Introduce a feature-flags module in the frontend that detects the backend conversion engine via /health and exposes typed feature flags. Chunking is enabled only in local engine mode.
This commit is contained in:
parent
0e302c30ea
commit
2e086cc4f3
7 changed files with 154 additions and 1 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,7 +12,7 @@ site/
|
|||
.run/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
*/CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router'
|
||||
import { useFeatureFlagStore } from '../features/feature-flags'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
const featureFlags = useFeatureFlagStore()
|
||||
featureFlags.load()
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
|||
2
frontend/src/features/feature-flags/index.ts
Normal file
2
frontend/src/features/feature-flags/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useFeatureFlagStore, type FeatureFlag } from './store'
|
||||
export { useFeatureFlag } from './useFeatureFlag'
|
||||
51
frontend/src/features/feature-flags/store.test.ts
Normal file
51
frontend/src/features/feature-flags/store.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useFeatureFlagStore } from './store'
|
||||
|
||||
const mockApiFetch = vi.fn()
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../settings/store', () => ({
|
||||
useSettingsStore: () => ({ apiUrl: 'http://localhost:8000' }),
|
||||
}))
|
||||
|
||||
describe('useFeatureFlagStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApiFetch.mockReset()
|
||||
})
|
||||
|
||||
it('starts unloaded with flags disabled', () => {
|
||||
const store = useFeatureFlagStore()
|
||||
expect(store.loaded).toBe(false)
|
||||
expect(store.isEnabled('chunking')).toBe(false)
|
||||
})
|
||||
|
||||
it('enables chunking when engine is local', async () => {
|
||||
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
|
||||
const store = useFeatureFlagStore()
|
||||
await store.load()
|
||||
expect(store.engine).toBe('local')
|
||||
expect(store.loaded).toBe(true)
|
||||
expect(store.isEnabled('chunking')).toBe(true)
|
||||
})
|
||||
|
||||
it('disables chunking when engine is remote', async () => {
|
||||
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
|
||||
const store = useFeatureFlagStore()
|
||||
await store.load()
|
||||
expect(store.engine).toBe('remote')
|
||||
expect(store.isEnabled('chunking')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles health endpoint failure gracefully', async () => {
|
||||
mockApiFetch.mockRejectedValue(new Error('Network error'))
|
||||
const store = useFeatureFlagStore()
|
||||
await store.load()
|
||||
expect(store.loaded).toBe(true)
|
||||
expect(store.error).toBe('Network error')
|
||||
expect(store.isEnabled('chunking')).toBe(false)
|
||||
})
|
||||
})
|
||||
60
frontend/src/features/feature-flags/store.ts
Normal file
60
frontend/src/features/feature-flags/store.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
import { useSettingsStore } from '../settings/store'
|
||||
|
||||
type ConversionEngine = 'local' | 'remote'
|
||||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
engine: ConversionEngine
|
||||
}
|
||||
|
||||
export type FeatureFlag = 'chunking'
|
||||
|
||||
interface FeatureFlagDef {
|
||||
description: string
|
||||
isEnabled: (ctx: FeatureFlagContext) => boolean
|
||||
}
|
||||
|
||||
interface FeatureFlagContext {
|
||||
engine: ConversionEngine | null
|
||||
}
|
||||
|
||||
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
||||
chunking: {
|
||||
description: 'Document chunking for RAG preparation',
|
||||
isEnabled: (ctx) => ctx.engine === 'local',
|
||||
},
|
||||
}
|
||||
|
||||
export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||
const engine = ref<ConversionEngine | null>(null)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const context = computed<FeatureFlagContext>(() => ({
|
||||
engine: engine.value,
|
||||
}))
|
||||
|
||||
function isEnabled(flag: FeatureFlag): boolean {
|
||||
if (!loaded.value) return false
|
||||
const def = featureRegistry[flag]
|
||||
return def.isEnabled(context.value)
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const settings = useSettingsStore()
|
||||
try {
|
||||
const data = await apiFetch<HealthResponse>(`${settings.apiUrl}/health`)
|
||||
engine.value = data.engine
|
||||
loaded.value = true
|
||||
error.value = null
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load feature flags'
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return { engine, loaded, error, isEnabled, load }
|
||||
})
|
||||
28
frontend/src/features/feature-flags/useFeatureFlag.test.ts
Normal file
28
frontend/src/features/feature-flags/useFeatureFlag.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useFeatureFlag } from './useFeatureFlag'
|
||||
import { useFeatureFlagStore } from './store'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({ apiFetch: vi.fn() }))
|
||||
|
||||
describe('useFeatureFlag', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('returns false before flags are loaded', () => {
|
||||
const flag = useFeatureFlag('chunking')
|
||||
expect(flag.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns reactive value matching store state', () => {
|
||||
const store = useFeatureFlagStore()
|
||||
store.$patch({ loaded: true, engine: 'local' })
|
||||
|
||||
const flag = useFeatureFlag('chunking')
|
||||
expect(flag.value).toBe(true)
|
||||
|
||||
store.$patch({ engine: 'remote' })
|
||||
expect(flag.value).toBe(false)
|
||||
})
|
||||
})
|
||||
7
frontend/src/features/feature-flags/useFeatureFlag.ts
Normal file
7
frontend/src/features/feature-flags/useFeatureFlag.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { computed } from 'vue'
|
||||
import { useFeatureFlagStore, type FeatureFlag } from './store'
|
||||
|
||||
export function useFeatureFlag(flag: FeatureFlag) {
|
||||
const store = useFeatureFlagStore()
|
||||
return computed(() => store.isEnabled(flag))
|
||||
}
|
||||
Loading…
Reference in a new issue