diff --git a/src/lib/features/dependent-features/createDependentFeaturesService.ts b/src/lib/features/dependent-features/createDependentFeaturesService.ts index c040c8e513..b317233a78 100644 --- a/src/lib/features/dependent-features/createDependentFeaturesService.ts +++ b/src/lib/features/dependent-features/createDependentFeaturesService.ts @@ -15,7 +15,7 @@ import { createFakeChangeRequestAccessService, } from '../change-request-access-service/createChangeRequestAccessReadModel'; import { FeaturesReadModel } from '../feature-toggle/features-read-model'; -import { FakeFeaturesReadModel } from '../feature-toggle/fake-features-read-model'; +import { FakeFeaturesReadModel } from '../feature-toggle/fakes/fake-features-read-model'; export const createDependentFeaturesService = ( db: Db, diff --git a/src/lib/features/dependent-features/dependent-features-service.ts b/src/lib/features/dependent-features/dependent-features-service.ts index 9c0f94c64f..f397fecc5f 100644 --- a/src/lib/features/dependent-features/dependent-features-service.ts +++ b/src/lib/features/dependent-features/dependent-features-service.ts @@ -8,7 +8,7 @@ import { User } from '../../server-impl'; import { SKIP_CHANGE_REQUEST } from '../../types'; import { IChangeRequestAccessReadModel } from '../change-request-access-service/change-request-access-read-model'; import { extractUsernameFromUser } from '../../util'; -import { IFeaturesReadModel } from '../feature-toggle/features-read-model-type'; +import { IFeaturesReadModel } from '../feature-toggle/types/features-read-model-type'; interface IDependentFeaturesServiceDeps { dependentFeaturesStore: IDependentFeaturesStore; diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts index 57da33ad40..935df3c332 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-toggle-store.ts @@ -12,6 +12,7 @@ import { } from 'lib/types/model'; import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service'; import { EnvironmentFeatureNames } from '../feature-toggle-store'; +import { FeatureConfigurationClient } from '../types/feature-toggle-strategies-store-type'; export default class FakeFeatureToggleStore implements IFeatureToggleStore { features: FeatureToggle[] = []; @@ -159,7 +160,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { userId?: number, archived: boolean = false, ): Promise { - return this.features.filter((f) => f.archived !== archived); + return this.features.filter((feature) => feature.archived !== archived); + } + + async getPlaygroundFeatures( + dependentFeaturesEnabled: boolean, + query?: IFeatureToggleQuery, + ): Promise { + return this.features.filter( + (feature) => feature, + ) as FeatureConfigurationClient[]; } async update( diff --git a/src/lib/features/feature-toggle/fake-features-read-model.ts b/src/lib/features/feature-toggle/fakes/fake-features-read-model.ts similarity index 68% rename from src/lib/features/feature-toggle/fake-features-read-model.ts rename to src/lib/features/feature-toggle/fakes/fake-features-read-model.ts index 402f07019f..3aa8943a41 100644 --- a/src/lib/features/feature-toggle/fake-features-read-model.ts +++ b/src/lib/features/feature-toggle/fakes/fake-features-read-model.ts @@ -1,4 +1,4 @@ -import { IFeaturesReadModel } from './features-read-model-type'; +import { IFeaturesReadModel } from '../types/features-read-model-type'; export class FakeFeaturesReadModel implements IFeaturesReadModel { featureExists(): Promise { diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index dc8796e733..db578f4b8a 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -27,6 +27,7 @@ import { IFeatureOverview, IFeatureStrategy, IFeatureTagStore, + IFeatureToggleClient, IFeatureToggleClientStore, IFeatureToggleQuery, IFeatureToggleStore, @@ -101,6 +102,7 @@ import { IPrivateProjectChecker } from '../private-project/privateProjectChecker import { IDependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model-type'; import EventService from '../../services/event-service'; import { DependentFeaturesService } from '../dependent-features/dependent-features-service'; +import isEqual from 'lodash.isequal'; interface IFeatureContext { featureName: string; @@ -130,7 +132,6 @@ export type FeatureNameCheckResultWithFeaturePattern = const oneOf = (values: string[], match: string) => { return values.some((value) => value === match); }; - class FeatureToggleService { private logger: Logger; @@ -1049,10 +1050,32 @@ class FeatureToggleService { async getPlaygroundFeatures( query?: IFeatureToggleQuery, ): Promise { - const result = await this.clientFeatureToggleStore.getPlayground( - query || {}, + // Remove with with feature flag + const [featuresFromClientStore, featuresFromFeatureToggleStore] = + await Promise.all([ + await this.clientFeatureToggleStore.getPlayground(query || {}), + await this.featureToggleStore.getPlaygroundFeatures( + this.flagResolver.isEnabled('dependentFeatures'), + query, + ), + ]); + + const equal = isEqual( + featuresFromClientStore, + featuresFromFeatureToggleStore, ); - return result; + + if (!equal) { + this.logger.warn( + 'features from client-feature-toggle-store is not equal to features from feature-toggle-store', + ); + } + + const features = this.flagResolver.isEnabled('useLastSeenRefactor') + ? featuresFromFeatureToggleStore + : featuresFromClientStore; + + return features as FeatureConfigurationClient[]; } /** @@ -1068,20 +1091,36 @@ class FeatureToggleService { userId?: number, archived: boolean = false, ): Promise { - let features = (await this.clientFeatureToggleStore.getAdmin({ - featureQuery: query, - userId: userId, - archived: false, - })) as FeatureToggle[]; + // Remove with with feature flag + const [featuresFromClientStore, featuresFromFeatureToggleStore] = + await Promise.all([ + (await this.clientFeatureToggleStore.getAdmin({ + featureQuery: query, + userId: userId, + archived: false, + })) as FeatureToggle[], + await this.featureToggleStore.getFeatureToggleList( + query, + userId, + archived, + ), + ]); - if (this.flagResolver.isEnabled('separateAdminClientApi')) { - features = await this.featureToggleStore.getFeatureToggleList( - query, - userId, - archived, + const equal = isEqual( + featuresFromClientStore, + featuresFromFeatureToggleStore, + ); + + if (!equal) { + this.logger.warn( + 'features from client-feature-toggle-store is not equal to features from feature-toggle-store diff', ); } + const features = this.flagResolver.isEnabled('useLastSeenRefactor') + ? featuresFromFeatureToggleStore + : featuresFromClientStore; + if (this.flagResolver.isEnabled('privateProjects') && userId) { const projectAccess = await this.privateProjectChecker.getUserAccessibleProjects( diff --git a/src/lib/features/feature-toggle/feature-toggle-store.ts b/src/lib/features/feature-toggle/feature-toggle-store.ts index 2b25ccccf5..4b80522001 100644 --- a/src/lib/features/feature-toggle/feature-toggle-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-store.ts @@ -14,14 +14,15 @@ import { IFeatureToggleStore } from './types/feature-toggle-store-type'; import { Db } from '../../db/db'; import { LastSeenInput } from '../../services/client-metrics/last-seen/last-seen-service'; import { NameExistsError } from '../../error'; -import { DEFAULT_ENV, ensureStringValue, mapValues } from '../../../lib/util'; -import { - IFeatureToggleClient, - IStrategyConfig, - ITag, - PartialDeep, -} from '../../../lib/types'; +import { DEFAULT_ENV } from '../../../lib/util'; + import { FeatureToggleListBuilder } from './query-builders/feature-toggle-list-builder'; +import { + buildFeatureToggleListFromRows, + buildPlaygroundFeaturesFromRows, +} from './feature-toggle-utils'; +import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type'; +import { IFlagResolver } from '../../../lib/types'; export type EnvironmentFeatureNames = { [key: string]: string[] }; @@ -57,123 +58,6 @@ interface VariantDTO { const TABLE = 'features'; const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments'; -const isUnseenStrategyRow = ( - feature: PartialDeep, - row: Record, -): boolean => { - return ( - row.strategy_id && - !feature.strategies?.find((s) => s?.id === row.strategy_id) - ); -}; - -const isNewTag = ( - feature: PartialDeep, - row: Record, -): boolean => { - return ( - row.tag_type && - row.tag_value && - !feature.tags?.some( - (tag) => tag?.type === row.tag_type && tag?.value === row.tag_value, - ) - ); -}; - -const addSegmentToStrategy = ( - feature: PartialDeep, - row: Record, -) => { - feature.strategies - ?.find((s) => s?.id === row.strategy_id) - ?.constraints?.push(...row.segment_constraints); -}; - -const addSegmentIdsToStrategy = ( - feature: PartialDeep, - row: Record, -) => { - const strategy = feature.strategies?.find((s) => s?.id === row.strategy_id); - if (!strategy) { - return; - } - if (!strategy.segments) { - strategy.segments = []; - } - strategy.segments.push(row.segment_id); -}; - -const rowToStrategy = (row: Record): IStrategyConfig => { - const strategy: IStrategyConfig = { - id: row.strategy_id, - name: row.strategy_name, - title: row.strategy_title, - constraints: row.constraints || [], - parameters: mapValues(row.parameters || {}, ensureStringValue), - sortOrder: row.sort_order, - }; - strategy.variants = row.strategy_variants || []; - return strategy; -}; - -const addTag = ( - feature: Record, - row: Record, -): void => { - const tags = feature.tags || []; - const newTag = rowToTag(row); - feature.tags = [...tags, newTag]; -}; - -const rowToTag = (row: Record): ITag => { - return { - value: row.tag_value, - type: row.tag_type, - }; -}; - -const buildFeatureToggleListFromRows = ( - rows: any[], - featureQuery?: IFeatureToggleQuery, -): FeatureToggle[] => { - const result = rows.reduce((acc, r) => { - const feature: PartialDeep = acc[r.name] ?? { - strategies: [], - }; - if (isUnseenStrategyRow(feature, r) && !r.strategy_disabled) { - feature.strategies?.push(rowToStrategy(r)); - } - if (isNewTag(feature, r)) { - addTag(feature, r); - } - if (featureQuery?.inlineSegmentConstraints && r.segment_id) { - addSegmentToStrategy(feature, r); - } else if (!featureQuery?.inlineSegmentConstraints && r.segment_id) { - addSegmentIdsToStrategy(feature, r); - } - - feature.impressionData = r.impression_data; - feature.enabled = !!r.enabled; - feature.name = r.name; - feature.description = r.description; - feature.project = r.project; - feature.stale = r.stale; - feature.type = r.type; - feature.lastSeenAt = r.last_seen_at; - feature.variants = r.variants || []; - feature.project = r.project; - - feature.favorite = r.favorite; - feature.lastSeenAt = r.last_seen_at; - feature.createdAt = r.created_at; - - acc[r.name] = feature; - return acc; - }, {}); - - return Object.values(result); -}; - export default class FeatureToggleStore implements IFeatureToggleStore { private db: Db; @@ -221,13 +105,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .then(this.rowToFeature); } - async getFeatureToggleList( - featureQuery?: IFeatureToggleQuery, - userId?: number, - archived: boolean = false, - ): Promise { - const environment = featureQuery?.environment || DEFAULT_ENV; - + private getBaseFeatureQuery = (archived: boolean, environment: string) => { const builder = new FeatureToggleListBuilder(this.db); builder @@ -236,13 +114,28 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .withStrategies(environment) .withFeatureEnvironments(environment) .withFeatureStrategySegments() - .withSegments() - .withDependentFeatureToggles() - .withFeatureTags(); + .withSegments(); + + return builder; + }; + + async getFeatureToggleList( + featureQuery?: IFeatureToggleQuery, + userId?: number, + archived: boolean = false, + ): Promise { + const environment = featureQuery?.environment || DEFAULT_ENV; + + const builder = this.getBaseFeatureQuery( + archived, + environment, + ).withFeatureTags(); + + builder.addSelectColumn('ft.tag_value as tag_value'); + builder.addSelectColumn('ft.tag_type as tag_type'); if (userId) { builder.withFavorites(userId); - builder.addSelectColumn( this.db.raw( 'favorite_features.feature is not null as favorite', @@ -257,6 +150,34 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return buildFeatureToggleListFromRows(rows, featureQuery); } + async getPlaygroundFeatures( + dependentFeaturesEnabled: boolean, + featureQuery: IFeatureToggleQuery, + ): Promise { + const environment = featureQuery?.environment || DEFAULT_ENV; + + const archived = false; + const builder = this.getBaseFeatureQuery(archived, environment); + + if (dependentFeaturesEnabled) { + builder.withDependentFeatureToggles(); + + builder.addSelectColumn('df.parent as parent'); + builder.addSelectColumn('df.variants as parent_variants'); + builder.addSelectColumn('df.enabled as parent_enabled'); + } + + const rows = await builder.internalQuery.select( + builder.getSelectColumns(), + ); + + return buildPlaygroundFeaturesFromRows( + rows, + dependentFeaturesEnabled, + featureQuery, + ); + } + async getAll( query: { archived?: boolean; diff --git a/src/lib/features/feature-toggle/feature-toggle-utils.ts b/src/lib/features/feature-toggle/feature-toggle-utils.ts new file mode 100644 index 0000000000..df603090dc --- /dev/null +++ b/src/lib/features/feature-toggle/feature-toggle-utils.ts @@ -0,0 +1,219 @@ +import { + PartialDeep, + IFeatureToggleClient, + IStrategyConfig, + ITag, + IFeatureToggleQuery, + FeatureToggle, +} from '../../types'; +import { mapValues, ensureStringValue } from '../../util'; +import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type'; + +const isUnseenStrategyRow = ( + feature: PartialDeep, + row: Record, +): boolean => { + return ( + row.strategy_id && + !feature.strategies?.find((s) => s?.id === row.strategy_id) + ); +}; + +const isNewTag = ( + feature: PartialDeep, + row: Record, +): boolean => { + return ( + row.tag_type && + row.tag_value && + !feature.tags?.some( + (tag) => tag?.type === row.tag_type && tag?.value === row.tag_value, + ) + ); +}; + +const addSegmentToStrategy = ( + feature: PartialDeep, + row: Record, +) => { + feature.strategies + ?.find((s) => s?.id === row.strategy_id) + ?.constraints?.push(...row.segment_constraints); +}; + +const addSegmentIdsToStrategy = ( + feature: PartialDeep, + row: Record, +) => { + const strategy = feature.strategies?.find((s) => s?.id === row.strategy_id); + if (!strategy) { + return; + } + if (!strategy.segments) { + strategy.segments = []; + } + strategy.segments.push(row.segment_id); +}; + +const rowToStrategy = (row: Record): IStrategyConfig => { + const strategy: IStrategyConfig = { + id: row.strategy_id, + name: row.strategy_name, + title: row.strategy_title, + constraints: row.constraints || [], + parameters: mapValues(row.parameters || {}, ensureStringValue), + sortOrder: row.sort_order, + }; + strategy.variants = row.strategy_variants || []; + return strategy; +}; + +const addTag = ( + feature: Record, + row: Record, +): void => { + const tags = feature.tags || []; + const newTag = rowToTag(row); + feature.tags = [...tags, newTag]; +}; + +const rowToTag = (row: Record): ITag => { + return { + value: row.tag_value, + type: row.tag_type, + }; +}; + +export const buildFeatureToggleListFromRows = ( + rows: any[], + featureQuery?: IFeatureToggleQuery, +): FeatureToggle[] => { + let result = rows.reduce((acc, r) => { + const feature: PartialDeep = acc[r.name] ?? { + strategies: [], + }; + if (isUnseenStrategyRow(feature, r) && !r.strategy_disabled) { + feature.strategies?.push(rowToStrategy(r)); + } + if (isNewTag(feature, r)) { + addTag(feature, r); + } + if (featureQuery?.inlineSegmentConstraints && r.segment_id) { + addSegmentToStrategy(feature, r); + } else if (!featureQuery?.inlineSegmentConstraints && r.segment_id) { + addSegmentIdsToStrategy(feature, r); + } + + feature.impressionData = r.impression_data; + feature.enabled = !!r.enabled; + feature.name = r.name; + feature.description = r.description; + feature.project = r.project; + feature.stale = r.stale; + feature.type = r.type; + feature.lastSeenAt = r.last_seen_at; + feature.variants = r.variants || []; + feature.project = r.project; + feature.createdAt = r.created_at; + feature.favorite = r.favorite; + + feature.lastSeenAt = r.last_seen_at; + + acc[r.name] = feature; + return acc; + }, {}); + + result = Object.values(result).map(({ strategies, ...rest }) => ({ + ...rest, + strategies: strategies + ?.sort((strategy1, strategy2) => { + if ( + typeof strategy1.sortOrder === 'number' && + typeof strategy2.sortOrder === 'number' + ) { + return strategy1.sortOrder - strategy2.sortOrder; + } + return 0; + }) + .map(({ title, sortOrder, ...strategy }) => ({ + ...strategy, + ...(title ? { title } : {}), + })), + })); + + return result; +}; + +export const buildPlaygroundFeaturesFromRows = ( + rows: any[], + dependentFeaturesEnabled: boolean, + featureQuery?: IFeatureToggleQuery, +): FeatureConfigurationClient[] => { + let result = rows.reduce((acc, r) => { + const feature: PartialDeep = acc[r.name] ?? { + strategies: [], + }; + if (isUnseenStrategyRow(feature, r) && !r.strategy_disabled) { + feature.strategies?.push(rowToStrategy(r)); + } + if (isNewTag(feature, r)) { + addTag(feature, r); + } + if (featureQuery?.inlineSegmentConstraints && r.segment_id) { + addSegmentToStrategy(feature, r); + } else if (!featureQuery?.inlineSegmentConstraints && r.segment_id) { + addSegmentIdsToStrategy(feature, r); + } + + feature.impressionData = r.impression_data; + feature.enabled = !!r.enabled; + feature.name = r.name; + feature.description = r.description; + feature.project = r.project; + feature.stale = r.stale; + feature.type = r.type; + feature.lastSeenAt = r.last_seen_at; + feature.variants = r.variants || []; + feature.project = r.project; + feature.lastSeenAt = r.last_seen_at; + + if (r.parent && dependentFeaturesEnabled) { + feature.dependencies = feature.dependencies || []; + feature.dependencies.push({ + feature: r.parent, + enabled: r.parent_enabled, + ...(r.parent_enabled ? { variants: r.parent_variants } : {}), + }); + } + + acc[r.name] = feature; + return acc; + }, {}); + + result = Object.values(result).map(({ strategies, ...rest }) => ({ + ...rest, + strategies: strategies + ?.sort((strategy1, strategy2) => { + if ( + typeof strategy1.sortOrder === 'number' && + typeof strategy2.sortOrder === 'number' + ) { + return strategy1.sortOrder - strategy2.sortOrder; + } + return 0; + }) + .map(({ title, sortOrder, ...strategy }) => ({ + ...strategy, + ...(title ? { title } : {}), + })), + })); + + return result; +}; + +interface Difference { + index: (string | number)[]; + reason: string; + valueA: any; + valueB: any; +} diff --git a/src/lib/features/feature-toggle/features-read-model.ts b/src/lib/features/feature-toggle/features-read-model.ts index 0e5c2e3817..6007f60e70 100644 --- a/src/lib/features/feature-toggle/features-read-model.ts +++ b/src/lib/features/feature-toggle/features-read-model.ts @@ -1,5 +1,5 @@ import { Db } from '../../db/db'; -import { IFeaturesReadModel } from './features-read-model-type'; +import { IFeaturesReadModel } from './types/features-read-model-type'; export class FeaturesReadModel implements IFeaturesReadModel { private db: Db; diff --git a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts index cbc3ec10cd..b3b8b5fca2 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-store-type.ts @@ -6,6 +6,7 @@ import { } from '../../../types/model'; import { Store } from '../../../types/stores/store'; import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service'; +import { FeatureConfigurationClient } from './feature-toggle-strategies-store-type'; export interface IFeatureToggleStoreQuery { archived: boolean; @@ -36,6 +37,10 @@ export interface IFeatureToggleStore extends Store { userId?: number, archived?: boolean, ): Promise; + getPlaygroundFeatures( + dependentFeaturesEnabled: boolean, + featureQuery?: IFeatureToggleQuery, + ): Promise; countByDate(queryModifiers: { archived?: boolean; project?: string; diff --git a/src/lib/features/feature-toggle/features-read-model-type.ts b/src/lib/features/feature-toggle/types/features-read-model-type.ts similarity index 100% rename from src/lib/features/feature-toggle/features-read-model-type.ts rename to src/lib/features/feature-toggle/types/features-read-model-type.ts