From 2e086cc4f3dcc32ca368f6c60b7da44a54f22c34 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 2 Apr 2026 11:25:02 +0200 Subject: [PATCH] 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. --- .gitignore | 2 +- frontend/src/app/main.ts | 5 ++ frontend/src/features/feature-flags/index.ts | 2 + .../src/features/feature-flags/store.test.ts | 51 ++++++++++++++++ frontend/src/features/feature-flags/store.ts | 60 +++++++++++++++++++ .../feature-flags/useFeatureFlag.test.ts | 28 +++++++++ .../features/feature-flags/useFeatureFlag.ts | 7 +++ 7 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/feature-flags/index.ts create mode 100644 frontend/src/features/feature-flags/store.test.ts create mode 100644 frontend/src/features/feature-flags/store.ts create mode 100644 frontend/src/features/feature-flags/useFeatureFlag.test.ts create mode 100644 frontend/src/features/feature-flags/useFeatureFlag.ts diff --git a/.gitignore b/.gitignore index 634e253..97313cf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ site/ .run/ .claude/ CLAUDE.md -*/CLAUDE.md +**/CLAUDE.md *.iml # OS diff --git a/frontend/src/app/main.ts b/frontend/src/app/main.ts index 4c8a9ad..9abcd74 100644 --- a/frontend/src/app/main.ts +++ b/frontend/src/app/main.ts @@ -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') diff --git a/frontend/src/features/feature-flags/index.ts b/frontend/src/features/feature-flags/index.ts new file mode 100644 index 0000000..10d3f98 --- /dev/null +++ b/frontend/src/features/feature-flags/index.ts @@ -0,0 +1,2 @@ +export { useFeatureFlagStore, type FeatureFlag } from './store' +export { useFeatureFlag } from './useFeatureFlag' diff --git a/frontend/src/features/feature-flags/store.test.ts b/frontend/src/features/feature-flags/store.test.ts new file mode 100644 index 0000000..683ed87 --- /dev/null +++ b/frontend/src/features/feature-flags/store.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/feature-flags/store.ts b/frontend/src/features/feature-flags/store.ts new file mode 100644 index 0000000..3954faf --- /dev/null +++ b/frontend/src/features/feature-flags/store.ts @@ -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 = { + chunking: { + description: 'Document chunking for RAG preparation', + isEnabled: (ctx) => ctx.engine === 'local', + }, +} + +export const useFeatureFlagStore = defineStore('feature-flags', () => { + const engine = ref(null) + const loaded = ref(false) + const error = ref(null) + + const context = computed(() => ({ + 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 { + const settings = useSettingsStore() + try { + const data = await apiFetch(`${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 } +}) diff --git a/frontend/src/features/feature-flags/useFeatureFlag.test.ts b/frontend/src/features/feature-flags/useFeatureFlag.test.ts new file mode 100644 index 0000000..5294218 --- /dev/null +++ b/frontend/src/features/feature-flags/useFeatureFlag.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/feature-flags/useFeatureFlag.ts b/frontend/src/features/feature-flags/useFeatureFlag.ts new file mode 100644 index 0000000..566e386 --- /dev/null +++ b/frontend/src/features/feature-flags/useFeatureFlag.ts @@ -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)) +}