1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

refactor: switch projectStore.getProjects with projectReadModel.getProjectsForAdminUi in project service (#7904)

Hooks up the new project read model and updates the existing project
service to use it instead when the flag is on.

In doing:
- creates a composition root for the read model
- includes it in IUnleashStores
- updates some existing methods to accept either the old or the new
model
- updates the OpenAPI schema to deprecate the old properties
This commit is contained in:
Thomas Heartman 2024-08-19 08:46:50 +02:00 committed by GitHub
parent 044da6866c
commit 79c3f8e975
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 114 additions and 45 deletions

View File

@ -51,6 +51,7 @@ import { FeatureLifecycleReadModel } from '../features/feature-lifecycle/feature
import { LargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model'; import { LargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model';
import { IntegrationEventsStore } from '../features/integration-events/integration-events-store'; import { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model'; import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model';
import { createProjectReadModel } from '../features/project/createProjectReadModel';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -177,6 +178,11 @@ export const createStores = (
largestResourcesReadModel: new LargestResourcesReadModel(db), largestResourcesReadModel: new LargestResourcesReadModel(db),
integrationEventsStore: new IntegrationEventsStore(db, { eventBus }), integrationEventsStore: new IntegrationEventsStore(db, { eventBus }),
featureCollaboratorsReadModel: new FeatureCollaboratorsReadModel(db), featureCollaboratorsReadModel: new FeatureCollaboratorsReadModel(db),
projectReadModel: createProjectReadModel(
db,
eventBus,
config.flagResolver,
),
}; };
}; };

View File

@ -0,0 +1,18 @@
import type EventEmitter from 'events';
import type { Db } from '../../server-impl';
import type { IProjectReadModel } from './project-read-model-type';
import type { IFlagResolver } from '../../types';
import { ProjectReadModel } from './project-read-model';
import { FakeProjectReadModel } from './fake-project-read-model';
export const createProjectReadModel = (
db: Db,
eventBus: EventEmitter,
flagResolver: IFlagResolver,
): IProjectReadModel => {
return new ProjectReadModel(db, eventBus, flagResolver);
};
export const createFakeProjectReadModel = (): IProjectReadModel => {
return new FakeProjectReadModel();
};

View File

@ -48,6 +48,10 @@ import {
createEventsService, createEventsService,
createFakeEventsService, createFakeEventsService,
} from '../events/createEventsService'; } from '../events/createEventsService';
import {
createFakeProjectReadModel,
createProjectReadModel,
} from './createProjectReadModel';
export const createProjectService = ( export const createProjectService = (
db: Db, db: Db,
@ -120,6 +124,12 @@ export const createProjectService = (
eventService, eventService,
); );
const projectReadModel = createProjectReadModel(
db,
eventBus,
config.flagResolver,
);
return new ProjectService( return new ProjectService(
{ {
projectStore, projectStore,
@ -131,6 +141,7 @@ export const createProjectService = (
projectStatsStore, projectStatsStore,
projectOwnersReadModel, projectOwnersReadModel,
projectFlagCreatorsReadModel, projectFlagCreatorsReadModel,
projectReadModel,
}, },
config, config,
accessService, accessService,
@ -184,6 +195,8 @@ export const createFakeProjectService = (
eventService, eventService,
); );
const projectReadModel = createFakeProjectReadModel();
return new ProjectService( return new ProjectService(
{ {
projectStore, projectStore,
@ -195,6 +208,7 @@ export const createFakeProjectService = (
featureEnvironmentStore, featureEnvironmentStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
projectReadModel,
}, },
config, config,
accessService, accessService,

View File

@ -1,13 +1,13 @@
import type { IProjectWithCount } from '../../types';
import type { import type {
IProjectOwnersReadModel, IProjectOwnersReadModel,
IProjectWithCountAndOwners, IProjectForUiWithOwners,
} from './project-owners-read-model.type'; } from './project-owners-read-model.type';
import type { ProjectForUi } from './project-read-model-type';
export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel { export class FakeProjectOwnersReadModel implements IProjectOwnersReadModel {
async addOwners( async addOwners(
projects: IProjectWithCount[], projects: ProjectForUi[],
): Promise<IProjectWithCountAndOwners[]> { ): Promise<IProjectForUiWithOwners[]> {
return projects.map((project) => ({ return projects.map((project) => ({
...project, ...project,
owners: [{ ownerType: 'system' }], owners: [{ ownerType: 'system' }],

View File

@ -0,0 +1,14 @@
import type { IProjectReadModel } from '../../types';
import type {
ProjectForUi,
ProjectForInsights,
} from './project-read-model-type';
export class FakeProjectReadModel implements IProjectReadModel {
getProjectsForAdminUi(): Promise<ProjectForUi[]> {
throw new Error('Method not implemented.');
}
getProjectsForInsights(): Promise<ProjectForInsights[]> {
throw new Error('Method not implemented.');
}
}

View File

@ -3,23 +3,24 @@ import getLogger from '../../../test/fixtures/no-logger';
import { type IUser, RoleName, type IGroup } from '../../types'; import { type IUser, RoleName, type IGroup } from '../../types';
import { randomId } from '../../util'; import { randomId } from '../../util';
import { ProjectOwnersReadModel } from './project-owners-read-model'; import { ProjectOwnersReadModel } from './project-owners-read-model';
import type { ProjectForUi } from './project-read-model-type';
jest.mock('../../util', () => ({ jest.mock('../../util', () => ({
...jest.requireActual('../../util'), ...jest.requireActual('../../util'),
generateImageUrl: jest.fn((input) => `https://${input.image_url}`), generateImageUrl: jest.fn((input) => `https://${input.image_url}`),
})); }));
const mockProjectWithCounts = (name: string) => ({ const mockProjectData = (name: string): ProjectForUi => ({
name, name,
id: name, id: name,
description: '',
featureCount: 0, featureCount: 0,
memberCount: 0, memberCount: 0,
mode: 'open' as const, mode: 'open' as const,
defaultStickiness: 'default' as const, health: 100,
staleFeatureCount: 0, createdAt: new Date(),
potentiallyStaleFeatureCount: 0, favorite: false,
avgTimeToProduction: 0, lastReportedFlagUsage: null,
lastFlagUpdate: null,
}); });
describe('unit tests', () => { describe('unit tests', () => {
@ -351,8 +352,8 @@ describe('integration tests', () => {
); );
const projectsWithOwners = await readModel.addOwners([ const projectsWithOwners = await readModel.addOwners([
mockProjectWithCounts(projectIdA), mockProjectData(projectIdA),
mockProjectWithCounts(projectIdB), mockProjectData(projectIdB),
]); ]);
expect(projectsWithOwners).toMatchObject([ expect(projectsWithOwners).toMatchObject([

View File

@ -1,13 +1,14 @@
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import { RoleName, type IProjectWithCount } from '../../types'; import { RoleName } from '../../types';
import { anonymise, generateImageUrl } from '../../util'; import { anonymise, generateImageUrl } from '../../util';
import type { import type {
GroupProjectOwner, GroupProjectOwner,
IProjectOwnersReadModel, IProjectOwnersReadModel,
IProjectWithCountAndOwners, IProjectForUiWithOwners,
ProjectOwnersDictionary, ProjectOwnersDictionary,
UserProjectOwner, UserProjectOwner,
} from './project-owners-read-model.type'; } from './project-owners-read-model.type';
import type { ProjectForUi } from './project-read-model-type';
const T = { const T = {
ROLE_USER: 'role_user', ROLE_USER: 'role_user',
@ -24,9 +25,9 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
} }
static addOwnerData( static addOwnerData(
projects: IProjectWithCount[], projects: ProjectForUi[],
owners: ProjectOwnersDictionary, owners: ProjectOwnersDictionary,
): IProjectWithCountAndOwners[] { ): IProjectForUiWithOwners[] {
return projects.map((project) => ({ return projects.map((project) => ({
...project, ...project,
owners: owners[project.id] || [{ ownerType: 'system' }], owners: owners[project.id] || [{ ownerType: 'system' }],
@ -138,9 +139,9 @@ export class ProjectOwnersReadModel implements IProjectOwnersReadModel {
} }
async addOwners( async addOwners(
projects: IProjectWithCount[], projects: ProjectForUi[],
anonymizeProjectOwners: boolean = false, anonymizeProjectOwners: boolean = false,
): Promise<IProjectWithCountAndOwners[]> { ): Promise<IProjectForUiWithOwners[]> {
const owners = await this.getAllProjectOwners(anonymizeProjectOwners); const owners = await this.getAllProjectOwners(anonymizeProjectOwners);
return ProjectOwnersReadModel.addOwnerData(projects, owners); return ProjectOwnersReadModel.addOwnerData(projects, owners);

View File

@ -1,4 +1,4 @@
import type { IProjectWithCount } from '../../types'; import type { TransitionalProjectData } from './project-read-model-type';
export type SystemOwner = { ownerType: 'system' }; export type SystemOwner = { ownerType: 'system' };
export type UserProjectOwner = { export type UserProjectOwner = {
@ -17,13 +17,13 @@ type ProjectOwners =
export type ProjectOwnersDictionary = Record<string, ProjectOwners>; export type ProjectOwnersDictionary = Record<string, ProjectOwners>;
export type IProjectWithCountAndOwners = IProjectWithCount & { export type IProjectForUiWithOwners = TransitionalProjectData & {
owners: ProjectOwners; owners: ProjectOwners;
}; };
export interface IProjectOwnersReadModel { export interface IProjectOwnersReadModel {
addOwners( addOwners(
projects: IProjectWithCount[], projects: TransitionalProjectData[],
anonymizeProjectOwners?: boolean, anonymizeProjectOwners?: boolean,
): Promise<IProjectWithCountAndOwners[]>; ): Promise<IProjectForUiWithOwners[]>;
} }

View File

@ -1,4 +1,4 @@
import type { ProjectMode } from '../../types'; import type { IProjectWithCount, ProjectMode } from '../../types';
import type { IProjectQuery } from './project-store-type'; import type { IProjectQuery } from './project-store-type';
export type ProjectForUi = { export type ProjectForUi = {
@ -16,6 +16,9 @@ export type ProjectForUi = {
lastFlagUpdate: Date | null; lastFlagUpdate: Date | null;
}; };
// @todo remove with flag useProjectReadModel
export type TransitionalProjectData = ProjectForUi | IProjectWithCount;
export type ProjectForInsights = { export type ProjectForInsights = {
id: string; id: string;
health: number; health: number;

View File

@ -49,13 +49,10 @@ export class ProjectReadModel implements IProjectReadModel {
private db: Db; private db: Db;
private timer: Function; private timer: Function;
private flagResolver: IFlagResolver;
constructor(
db: Db,
eventBus: EventEmitter, private flagResolver: IFlagResolver;
flagResolver: IFlagResolver,
) { constructor(db: Db, eventBus: EventEmitter, flagResolver: IFlagResolver) {
this.db = db; this.db = db;
this.timer = (action) => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {

View File

@ -82,7 +82,9 @@ beforeAll(async () => {
await stores.accessStore.addUserToRole(opsUser.id, 1, ''); await stores.accessStore.addUserToRole(opsUser.id, 1, '');
const config = createTestConfig({ const config = createTestConfig({
getLogger, getLogger,
experimental: { flags: { archiveProjects: true } }, experimental: {
flags: { archiveProjects: true, useProjectReadModel: true },
},
}); });
eventService = createEventsService(db.rawDatabase, config); eventService = createEventsService(db.rawDatabase, config);
accessService = createAccessService(db.rawDatabase, config); accessService = createAccessService(db.rawDatabase, config);
@ -323,9 +325,7 @@ test('should revive project', async () => {
const project = { const project = {
id: 'test-revive', id: 'test-revive',
name: 'New project', name: 'New project',
description: 'Blah',
mode: 'open' as const, mode: 'open' as const,
defaultStickiness: 'default',
}; };
await projectService.createProject(project, user, TEST_AUDIT_USER); await projectService.createProject(project, user, TEST_AUDIT_USER);
@ -347,9 +347,7 @@ test('should not be able to archive project with flags', async () => {
const project = { const project = {
id: 'test-archive-with-flags', id: 'test-archive-with-flags',
name: 'New project', name: 'New project',
description: 'Blah',
mode: 'open' as const, mode: 'open' as const,
defaultStickiness: 'default',
}; };
await projectService.createProject(project, user, auditUser); await projectService.createProject(project, user, auditUser);
await stores.featureToggleStore.create(project.id, { await stores.featureToggleStore.create(project.id, {
@ -2748,9 +2746,7 @@ test('should get project settings with mode', async () => {
); );
expect(foundProjectOne!.mode).toBe('private'); expect(foundProjectOne!.mode).toBe('private');
expect(foundProjectOne!.defaultStickiness).toBe('clientId');
expect(foundProjectTwo!.mode).toBe('open'); expect(foundProjectTwo!.mode).toBe('open');
expect(foundProjectTwo!.defaultStickiness).toBe('default');
}); });
describe('create project with environments', () => { describe('create project with environments', () => {

View File

@ -32,7 +32,6 @@ import {
type IProjectRoleUsage, type IProjectRoleUsage,
type IProjectStore, type IProjectStore,
type IProjectUpdate, type IProjectUpdate,
type IProjectWithCount,
type IUnleashConfig, type IUnleashConfig,
type IUnleashStores, type IUnleashStores,
MOVE_FEATURE_TOGGLE, MOVE_FEATURE_TOGGLE,
@ -54,6 +53,7 @@ import {
ProjectUserUpdateRoleEvent, ProjectUserUpdateRoleEvent,
RoleName, RoleName,
SYSTEM_USER_ID, SYSTEM_USER_ID,
type IProjectReadModel,
} from '../../types'; } from '../../types';
import type { import type {
IProjectAccessModel, IProjectAccessModel,
@ -87,6 +87,7 @@ import type { IProjectFlagCreatorsReadModel } from './project-flag-creators-read
import { throwExceedsLimitError } from '../../error/exceeds-limit-error'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type { ApiTokenService } from '../../services/api-token-service'; import type { ApiTokenService } from '../../services/api-token-service';
import type { TransitionalProjectData } from './project-read-model-type';
type Days = number; type Days = number;
type Count = number; type Count = number;
@ -161,6 +162,8 @@ export default class ProjectService {
private eventBus: EventEmitter; private eventBus: EventEmitter;
private projectReadModel: IProjectReadModel;
constructor( constructor(
{ {
projectStore, projectStore,
@ -172,6 +175,7 @@ export default class ProjectService {
featureEnvironmentStore, featureEnvironmentStore,
accountStore, accountStore,
projectStatsStore, projectStatsStore,
projectReadModel,
}: Pick< }: Pick<
IUnleashStores, IUnleashStores,
| 'projectStore' | 'projectStore'
@ -183,6 +187,7 @@ export default class ProjectService {
| 'featureEnvironmentStore' | 'featureEnvironmentStore'
| 'accountStore' | 'accountStore'
| 'projectStatsStore' | 'projectStatsStore'
| 'projectReadModel'
>, >,
config: IUnleashConfig, config: IUnleashConfig,
accessService: AccessService, accessService: AccessService,
@ -214,16 +219,18 @@ export default class ProjectService {
this.isEnterprise = config.isEnterprise; this.isEnterprise = config.isEnterprise;
this.resourceLimits = config.resourceLimits; this.resourceLimits = config.resourceLimits;
this.eventBus = config.eventBus; this.eventBus = config.eventBus;
this.projectReadModel = projectReadModel;
} }
async getProjects( async getProjects(
query?: IProjectQuery, query?: IProjectQuery,
userId?: number, userId?: number,
): Promise<IProjectWithCount[]> { ): Promise<TransitionalProjectData[]> {
const projects = await this.projectStore.getProjectsWithCounts( const getProjects = this.flagResolver.isEnabled('useProjectReadModel')
query, ? () => this.projectReadModel.getProjectsForAdminUi(query, userId)
userId, : () => this.projectStore.getProjectsWithCounts(query, userId);
);
const projects = await getProjects();
if (userId) { if (userId) {
const projectAccess = const projectAccess =
@ -243,8 +250,8 @@ export default class ProjectService {
} }
async addOwnersToProjects( async addOwnersToProjects(
projects: IProjectWithCount[], projects: TransitionalProjectData[],
): Promise<IProjectWithCount[]> { ): Promise<TransitionalProjectData[]> {
const anonymizeProjectOwners = this.flagResolver.isEnabled( const anonymizeProjectOwners = this.flagResolver.isEnabled(
'anonymizeProjectOwners', 'anonymizeProjectOwners',
); );

View File

@ -3,7 +3,7 @@ import type { FromSchema } from 'json-schema-to-ts';
export const projectSchema = { export const projectSchema = {
$id: '#/components/schemas/projectSchema', $id: '#/components/schemas/projectSchema',
type: 'object', type: 'object',
additionalProperties: false, // additionalProperties: false, // todo: re-enable when flag projectListImprovements is removed
required: ['id', 'name'], required: ['id', 'name'],
description: description:
'A definition of the project used for projects listing purposes', 'A definition of the project used for projects listing purposes',
@ -19,6 +19,7 @@ export const projectSchema = {
description: 'The name of this project', description: 'The name of this project',
}, },
description: { description: {
deprecated: true,
type: 'string', type: 'string',
nullable: true, nullable: true,
example: 'DX squad feature release', example: 'DX squad feature release',
@ -36,11 +37,13 @@ export const projectSchema = {
description: 'The number of features this project has', description: 'The number of features this project has',
}, },
staleFeatureCount: { staleFeatureCount: {
deprecated: true,
type: 'number', type: 'number',
example: 10, example: 10,
description: 'The number of stale features this project has', description: 'The number of stale features this project has',
}, },
potentiallyStaleFeatureCount: { potentiallyStaleFeatureCount: {
deprecated: true,
type: 'number', type: 'number',
example: 10, example: 10,
description: description:
@ -58,6 +61,7 @@ export const projectSchema = {
format: 'date-time', format: 'date-time',
}, },
updatedAt: { updatedAt: {
deprecated: true,
type: 'string', type: 'string',
format: 'date-time', format: 'date-time',
nullable: true, nullable: true,
@ -85,12 +89,14 @@ export const projectSchema = {
"The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.",
}, },
defaultStickiness: { defaultStickiness: {
deprecated: true,
type: 'string', type: 'string',
example: 'userId', example: 'userId',
description: description:
'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy', 'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy',
}, },
avgTimeToProduction: { avgTimeToProduction: {
deprecated: true,
type: 'number', type: 'number',
example: 10, example: 10,
description: description:

View File

@ -568,6 +568,7 @@ export interface ICustomRole extends IRole {
description: string; description: string;
} }
// @deprecated Remove with flag useProjectReadModel
export interface IProjectWithCount extends IProject { export interface IProjectWithCount extends IProject {
featureCount: number; featureCount: number;
staleFeatureCount: number; staleFeatureCount: number;

View File

@ -48,6 +48,7 @@ import { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/featur
import { ILargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model-type'; import { ILargestResourcesReadModel } from '../features/metrics/sizes/largest-resources-read-model-type';
import type { IntegrationEventsStore } from '../features/integration-events/integration-events-store'; import type { IntegrationEventsStore } from '../features/integration-events/integration-events-store';
import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types/feature-collaborators-read-model-type';
import type { IProjectReadModel } from '../features/project/project-read-model-type';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -100,6 +101,7 @@ export interface IUnleashStores {
largestResourcesReadModel: ILargestResourcesReadModel; largestResourcesReadModel: ILargestResourcesReadModel;
integrationEventsStore: IntegrationEventsStore; integrationEventsStore: IntegrationEventsStore;
featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel;
projectReadModel: IProjectReadModel;
} }
export { export {
@ -151,4 +153,5 @@ export {
ILargestResourcesReadModel, ILargestResourcesReadModel,
IFeatureCollaboratorsReadModel, IFeatureCollaboratorsReadModel,
type IntegrationEventsStore, type IntegrationEventsStore,
type IProjectReadModel,
}; };

View File

@ -51,6 +51,7 @@ import { FakeFeatureStrategiesReadModel } from '../../lib/features/feature-toggl
import { FakeFeatureLifecycleReadModel } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-read-model'; import { FakeFeatureLifecycleReadModel } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-read-model';
import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model'; import { FakeLargestResourcesReadModel } from '../../lib/features/metrics/sizes/fake-largest-resources-read-model';
import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model'; import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-toggle/fake-feature-collaborators-read-model';
import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel';
const db = { const db = {
select: () => ({ select: () => ({
@ -111,6 +112,7 @@ const createStores: () => IUnleashStores = () => {
largestResourcesReadModel: new FakeLargestResourcesReadModel(), largestResourcesReadModel: new FakeLargestResourcesReadModel(),
integrationEventsStore: {} as IntegrationEventsStore, integrationEventsStore: {} as IntegrationEventsStore,
featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(), featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(),
projectReadModel: createFakeProjectReadModel(),
}; };
}; };