@produce8/p8-cube-helper
TypeScript icon, indicating that this package has built-in type declarations

17.4.0 • Public • Published

BeaverdamJS (p8-cube-helper)

semantic-release: conventional Tests Release

npm: @produce8/p8-cube-helper

Introduction

BeaverdamJS (p8-cube-helper) utilizes domain driven design.

Domain-Driven Design (DDD) is an approach to software development that focuses on aligning the software design with the business domain it represents. It emphasizes understanding the domain and using that understanding to drive the design and implementation of the software system.

Domain-driven Design Layers

Domain-driven Design (DDD) typically involves the following layers:

  1. Domain Layer: This layer represents the core business logic and rules of the application. It contains the domain entities, value objects, and business logic implementations.
  2. Application Layer: The application layer acts as a bridge between the domain layer and the infrastructure layer. It coordinates the execution of use cases or application services and translates the inputs and outputs between the domain and infrastructure layers.
  3. Infrastructure Layer: The infrastructure layer provides the technical infrastructure necessary to support the application. It includes components such as databases, external services, frameworks, and UI.
  4. Interface Layer: The Interface layer is an additional layer in Domain-Driven Design that focuses on defining the interfaces through which the application interacts with external systems or users. It includes components such as controllers, DTOs, queries and commands.

These layers work together to create a software system that aligns with the business domain and promotes maintainability, extensibility, and flexibility.

Project structure

/src
 /common
  /ddd
  /decorators
  /enum
  /exceptions
  /guards
  /interface
  /port
  /utils
 /core
  /application
   /services
   /ports
  /domain
   /entities
   /aggregates
   /services
 /infrastructure
  /adapters
  /mappers
 /interface
  /controllers
  /presenters
  /dtos
  /queries
  /commands

File Naming Convention

<feature-name>|<entity-name>[.<request>|<response>].<type>.ts

Example:
- DTOs:
 - get-app-metrics-totals-whole-team.request.dto.ts
 - get-app-metrics-totals-whole-team.response.dto.ts
- Controller:
 - get-app-metrics-totals-whole-team.controller.ts
- Composite Controller:
 - get-app-metrics-totals-whole-team.composite-controller.ts
- Query:
 - get-app-metrics-totals-whole-team.query.ts
- Command:
 - expand-app-metrics-totals-whole-team.command.ts
- Entity:
 - app-metrics-totals-whole-team.entity.ts
- Mapper:
 - app-metrics-totals-whole-team.mapper.ts
- Presenter:
 - get-app-metrics-totals-whole-team.presenter.ts
- Application service:
 - get-app-metrics-totals-whole-team.application-service.ts
- Application service:
 - analytics-activity.port.ts
- Domain service:
 - get-app-metrics-totals-whole-team.domain-service.ts

The Process

Before you start coding

Before writing any code, sit down with the frontend developer and work through the Figma mockups for a given feature to produce interfaces that define the minimal logically grouped queries that can be performed. If a component consists of; a chart, an average for this period value and an all time average value, the data that needs to be queried for these respective sub components can’t be queried together. These define your domains!

These domains define the controllers you will then build in the cube helper package.

Heres an example of a minimal interface:

interface CubeRequestConfig {
  token: string;
}

interface GetAppMetricsTotalsWholeTeamRequestDto {
  params: {
    startDate: string;
    endDate: string;
    teamId: string;
    userIds: string[];
  };
  config: CubeRequestConfig;
}

interface GetAppMetricsTotalsWholeTeamResponseDto {
  timeInApp: Number;
  timeInCalls: Number;
}

Once the minimal interfaces (domains) have been defined, you can then use those interfaces to extrapolate the rest of your feature. Those interfaces become the request and response DTOs used by your controllers.

Anatomy of a feature

A complete feature consists of the following components:

  • request & response DTO’s (interface layer)
  • a controller or composite controller (interface layer)
  • a query or command (interface layer)
  • an application service (core/application layer)
  • a domain entity/ aggregate root (core/domain layer)
  • an entity/aggregate root DTO mapper (infrastructure layer)
  • a Presenter if using a composite controller

Data Transfer Object (DTO)

  • the DTOs will be directly referenced by the controller and the mapper for a given feature.

Example:

interface CubeRequestConfig {
  token: string;
}

interface GetAppMetricsTotalsWholeTeamRequestDto {
  params: {
    startDate: string;
    endDate: string;
    teamId: string;
    userIds: string[];
  };
  config: CubeRequestConfig;
}

interface GetAppMetricsTotalsWholeTeamResponseDto {
  timeInApp: Number;
  timeInCalls: Number;
}

Controller

  • The Controller will handle constructing the query and executing it.
  • A controller has 3 dependencies:
    • logManager
    • Query
    • Mapper
  • The controller returns the response DTO

Example:

export class GetAppMetricsTotalWholeTeamController {
  constructor(
    protected readonly logManager: LogManager,
    protected readonly query: GetAppMetricsTotalWholeTeamQuery,
    protected readonly mapper: AppMetricsTotalWholeTeamMapper
  ) {}

  async get(
    props: GetAppMetricsTotalsWholeTeamRequestDto
  ): Promise<GetAppMetricsTotalsWholeTeamResponseDto> {
    try {
      const result = await this.query.execute(props);
      return this.mapper.toResponse(result);
    } catch (error) {
      throw new Error(
        `AppMetricsTotalWholeTeamComponentController.get encountered an error ${error}`
      );
    }
  }
}

Query/Command

  • The query functions as the executor of the core application service. We intentionally violate a core principle of domain driven design here. Typically the query or command would operate as an interface publishing an event to some internal event stream and the core application service would consume this message downstream. This is considered event driven architecture. As we don’t have access to a message broker in the execution context (a browser client) we can’t utilize this feature.
  • Dependencies:
    • logManager
    • core application service
  • The query returns a domain layer entity
  • Whats the difference between a query or command?
    • It’s really just semantics. A query is responsible for retrieving something, whereas a command is responsible for performing some action
    • Commands don’t necessarily require an application-service. a domain-service will work just fine if the feature doesn’t require accessing the outside world.

Example:

export class GetAppMetricsTotalWholeTeamQuery {
  constructor(
    protected readonly logManager: LogManager,
    protected readonly service: GetAppMetricsTotalWholeTeamApplicationService
  ) {}
  async execute(
    props: GetAppMetricsTotalsWholeTeamRequestDto
  ): Promise<AppMetricsTotalWholeTeamEntity> {
    try {
      return await this.service.execute(props);
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }
}

export class ExpandAppMetricsTotalWholeTeamCommand {
  constructor(
    protected readonly logManager: LogManager,
    // this can be an application service if you are sending data to some external
    // system. if you never need to leave the domain layer, a domain
    // service is just fine!
    protected readonly service: ExpandAppMetricsTotalWholeTeamDomainService
  ) {}
  async execute(
    props: ExpansAppMetricsTotalsWholeTeamRequestDto
  ): Promise<AppMetricsTotalWholeTeamEntity> {
    try {
      return await this.service.execute(props);
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }
}

Core Application Service

  • The core application service is responsible for reaching out to the external system with the use of a port and returning a domain entity.
  • Dependencies:
    • logManager
    • Port
    • Domain Service (if needed)
  • Returns a domain layer entity or aggregate root
  • Where an aggregate root is required, this service can make more than port call method to retrieve the necessary data and return the appropriate domain layer entity
  • Can have logic related to what queries are called with what time dimensions, measures and dimensions, but hydration, constructing aggregate roots and other domain logic is done in the Domain Service

Example:

export class GetAppMetricsTotalWholeTeamApplicationService {
  constructor(
    protected readonly logManager: LogManager,
    protected readonly port: AnalyticsActivityPort,
    protected readonly domainService: GetAppMetricsTotalWholeTeamDomainService
  ) {}

  async execute(
    props: GetAppMetricsTotalsWholeTeamRequestDto
  ): Promise<AppMetricsTotalWholeTeamEntity> {
    try {
      const result: AppMetricsTotalWholeTeamEntity[] =
        await this.port.getAppMetricsTotalsWholeTeam({
          startDate: props.params.startDate,
          endDate: props.params.endDate,
          teamId: props.params.teamId,
      })
      return this.domainService({ entity: result });
    } catch (error) {
      throw new Error(
        `GetAppMetricsTotalWholeTeamService.execute encountered an error ${error}`
      );
    }
  }
}

The Port

  • Ports are what the application-service uses to interface with some adapter or repository that accesses an external system.
  • The port is typically schema bound, not domain bound. But the methods on the port are domain bound.
  • A port has 2 dependencies:
    • logManager
    • adapter/repository
  • The port will leverage the guard class to ensure class validation occurs on the response from the adapter before attempting to map to a domain entity.

Example:

import { Guard } from 'src/common/guards/guard';

export class AnalyticsActivityPort {
    constructor(
     protected readonly logManager: LogManager,
     protected readonly adapter: CubeAdapter
    ) {
    }

    async getAppMetricsTotalsWholeTeam(props:{
     startDate: string,
    endDate: string,
    teamId: string
  }): Promise<AppMetricsTotalWholeTeamEntity> {
      try {
      const result: IAppMetricsTotalWholeTeamPersistenceDto[] =
        await this.adapter.query(props.token, {
          measures: [
            IAnalyticsEventEnum.Count,
            IAnalyticsEventEnum.TotalDuration,
          ],
          timeDimensions: [
            {
              dimension: IAnalyticsEventEnum.LocalStartTime,
              dateRange: [props.startDate, props.endDate],
            },
          ],
          filters: [
            {
              member: IAnalyticsEventEnum.EventType,
              operator: 'equals',
              values: [
                AnalyticsEventTypes.INTERNAL,
                AnalyticsEventTypes.EXTERNAL,
              ],
            },
          ],
        });
      if (result.length > 0) {
        const filteredResults = await Guard.filterValidResponses(
          IAppMetricsTotalWholeTeamPersistenceDto,
          result
        );

        const [entity] = filteredResults.map((record) => {
          return SelfCalendarScoreMapper.toDomain(record);
        });
        return entity;
      }
      return null;
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }
}

The Domain Service

The Domain Service is for orchestrating relationships between domain objects: ie Entities and Aggregate Roots - as opposed to the Application Service which interfaces with external data

  • Hydrate missing values with empty entities
  • Add values from the FE to the entity via setters ie entity.setHourlyRate, where hourly rate is a FE input, since entities input props should only be values that are fetched from Cube.
  • Construct Aggregate Roots out of multiples Entities if needed - the domain services could take a “thisPeriod” and a “lastPeriod” entity and construct an aggregate out of them, or construct an array of entities for a measure (meeting type, appId etc) into a single aggregate.
  • The only dependency is logManager
  • It lives in the domain layer, and is called from the Application Service
export interface GetAppMetricsTotalWholeTeamDomainServiceProps {
  entity: AppMetricsTotalWholeTeamEntity
}

export class GetAppMetricsTotalWholeTeamDomainService {
  constructor(protected readonly logManager: LogManager) {}

  execute(
    props: GetAppMetricsTotalWholeTeamDomainServiceProps
  ): AppMetricsTotalWholeTeamEntity {
    try {
     // perform hydration on missing values
     let entity = props.entity
     if (!entity) {
         AppMetricsTotalWholeTeamEntity.create({
           timeInApp: 0,
           timeInCalls: 0
          }
         )
        }

        // if the entity needs to access any values from the FE rather than from Cube values
        // add them with setter methods here

        // if an aggregate root is returned from the application service,
        // construct it out of the inputted entities and return here.

       return entity
    } catch (error) {
      throw new Error(
        `GetAppMetricsTotalWholeTeamDomainService.execute encountered an error ${error}`
      );
    }
  }
}

The Domain Entity or Aggregate Root

  • the entity holds the data returned from the persistence layer and is used by the mapper to mutate it into something that can be used in each layer of the application
  • Where an aggregate-root entity is required, a collection of entities can be created and stored under the aggregates directory in the core domain layer.
  • As we typically require mathematical calculations be performed on our cube data in the form of averages/ deviations etc. This can be achieved in the entity/aggregate root via the use of getters. This getter is then referenced in the mappers’ toResponse method when returning from the controller.

Example:

interface AppMetricsTotalWholeTeamEntityProps {
  timeInApp: number;
  timeInCalls: number;
}

class AppMetricsTotalWholeTeamEntity extends Entity<AppMetricsTotalWholeTeamEntityProps> {
  protected _id: string;
  protected _someSecretInternalValue: number;
  static create(
    props: AppMetricsTotalWholeTeamEntityProps,
    entityId?: string
  ): AppMetricsTotalWholeTeamEntity {
    const id = entityId || v4();
    const user = new AppMetricsTotalWholeTeamEntity({
      id,
      props,
    });
    return user;
  }
  public validate(): void {}

  get timeInApp() {
    return this.props.timeInApp;
  }

  get timeInCalls() {
    return this.props.timeInCalls;
  }

  // setters can be used in the following approach where data needs to be
  // drilled down from the interface layer or another domain entity.
  setSomeSecretInternalValue(value: number): void {
   this._someSecretInternalValue = value;
  }

  get timeInCallsMinusSecretValue(): number {
   return this.props.timeInCalls - this._someSecretInternalValue;
  }

 // logic such as mathematical calculations can be achieved as such.
 // these can then be invoked in the toResponse method of the mapper.
 get someMathematicalCalculation() {
  return Math.round(this.timeInApp / this.timeInCalls)
 }
}

Mapper

  • The mapper lives in the infrastructure layer and is used to massage the data into an interface that each layer can use.
  • dependencies:
    • persistence DTO, which uses appropriate class validator decorators
    • domain layer interface (entity/aggregate root)
    • response DTO interface

Example:

export class IAppMetricsTotalWholeTeamPersistenceDto {
  @IsNotEmpty()
  [ITeamAnalyticsActivityV2Enum.TotalDuration]: string;

  @IsNotEmpty()
  [ITeamAnalyticsActivityV2Enum.TotalCallDuration]: string;
}

export class AppMetricsTotalWholeTeamMapper
  implements
    Mapper<
      IAppMetricsTotalWholeTeamPersistenceDto, // <-- persistence
      AppMetricsTotalWholeTeamEntity, // <-- entity
      GetAppMetricsTotalsWholeTeamResponseDto // <-- dto
    >
{
  toPersistence(
    entity: AppMetricsTotalWholeTeamEntity
  ): IAppMetricsTotalWholeTeamPersistenceDto {
    return AppMetricsTotalWholeTeamMapper.toPersistence(entity);
  }
  static toPersistence(
    entity: AppMetricsTotalWholeTeamEntity
  ): IAppMetricsTotalWholeTeamPersistenceDto {
    return {
      [ITeamAnalyticsActivityV2Enum.TotalDuration]: String(entity.timeInApp),
      [ITeamAnalyticsActivityV2Enum.TotalCallDuration]: String(
        entity.timeInCalls
      ),
    };
  }

  toDomain(
    record: IAppMetricsTotalWholeTeamPersistenceResponse
  ): AppMetricsTotalWholeTeamEntity {
    return AppMetricsTotalWholeTeamMapper.toDomain(record);
  }
  static toDomain(
    record: IAppMetricsTotalWholeTeamPersistenceResponse
  ): AppMetricsTotalWholeTeamEntity {
    const entity = AppMetricsTotalWholeTeamEntity.create({
      timeInApp: Number(record[ITeamAnalyticsActivityV2Enum.TotalDuration]),
      timeInCalls: Number(
        record[ITeamAnalyticsActivityV2Enum.TotalCallDuration]
      ),
    });
    return entity;
  }

  toResponse(
    entity: AppMetricsTotalWholeTeamEntity
  ): GetAppMetricsTotalsWholeTeamResponseDto {
    return AppMetricsTotalWholeTeamMapper.toResponse(entity);
  }

  static toResponse(
    entity: AppMetricsTotalWholeTeamEntity
  ): GetAppMetricsTotalsWholeTeamResponseDto {
    return {
   timeInApp: entity.timeInApp,
   timeInCalls: entity.timeInCalls,
   // someAvgValue: entity.someMathematicalCalculation < -- math operations delayed as long as possible
  };
  }
}

Presenter

  • A presenter is a type of interface mapper responsible for composing many sub mappers. A presenter is used in the context of a composite-controller and is used to compose the result of many entities to the client.
  • A presenter has multiple domain mapper dependencies:
    • Mapper

Example:

type ScoreAggregateRoots =
  | SelfDigitalIntensityScoreAggregateRoot
  | SelfCalendarScoreAggregateRoot
  | SelfDigitalWorkingHoursScoreAggregateRoot
  | SelfFocusScoreAggregateRoot
  | SelfTimeInCallsScoreAggregateRoot
  | SelfScreentimeScoreAggregateRoot;

export interface GetSelfAllScoresPresenterProps
  extends Record<string, ScoreAggregateRoots> {
  digitalIntensityScore: SelfDigitalIntensityScoreAggregateRoot;
  digitalWorkingHoursScore: SelfDigitalWorkingHoursScoreAggregateRoot;
  focusScore: SelfFocusScoreAggregateRoot;
  screentimeScore: SelfScreentimeScoreAggregateRoot;
  calendarScore: SelfCalendarScoreAggregateRoot;
  timeInCallsScore: SelfTimeInCallsScoreAggregateRoot;
}

export class GetSelfAllScoresPresenter
  implements
    Presenter<
      ScoreAggregateRoots,
      Omit<SelfGetAllScoresResponseDto, 'startDate' | 'endDate'>
    >
{
  constructor(
    protected readonly logManager: LogManager,
    protected readonly digitalIntensityMapper: SelfDigitalIntensityScoreAggregateRootMapper,
    protected readonly digitalWorkingHoursMapper: SelfDigitalWorkingHoursScoreAggregateRootMapper,
    protected readonly focusMapper: SelfFocusScoreAggregateRootMapper,
    protected readonly screentimeMapper: SelfScreentimeScoreAggregateRootMapper,
    protected readonly calendarMapper: SelfCalendarScoreAggregateRootMapper,
    protected readonly timeInCallsMapper: SelfTimeInCallsScoreAggregateRootMapper
  ) {}

  /**
   * Remove legacy 'hasData' property from the response once the frontend is updated to handle empty data
   */
  toResponse(
    entities: GetSelfAllScoresPresenterProps
  ): Omit<SelfGetAllScoresResponseDto, 'startDate' | 'endDate'> {
    try {
      const digitalIntensity = this.digitalIntensityMapper.toResponse(
        entities.digitalIntensityScore
      );
      const digitalWorkingHours = this.digitalWorkingHoursMapper.toResponse(
        entities.digitalWorkingHoursScore
      );
      const focus = this.focusMapper.toResponse(entities.focusScore);
      const screentime = this.screentimeMapper.toResponse(
        entities.screentimeScore
      );
      const calendar = this.calendarMapper.toResponse(entities.calendarScore);
      const timeInCalls = this.timeInCallsMapper.toResponse(
        entities.timeInCallsScore
      );

      const hasData =
        !digitalIntensity ||
        !digitalWorkingHours ||
        !focus ||
        !screentime ||
        !calendar ||
        !timeInCalls
          ? false
          : true;

      return {
        hasData,
        digitalIntensity,
        workingHours: digitalWorkingHours,
        focus,
        screenTime: screentime,
        timeInMeetings: calendar,
        timeInCalls,
      };
    } catch (error) {
      throw new Error(
        `GetSelfAllScoresPresenter.toResponse encountered an error ${error}`
      );
    }
  }
}

Composite-Controller

  • A composite controller is a superset of a typical controller that leverages multiple queries and a presenter to compose multiple use cases. This can be used when there are multiple domains that need to be composed in a single query.
  • Note that a composite controller is capable of executing these queries in parallel, making it quite performant for larger use cases.
  • A composite controller has 2 primary dependencies and a varying length of query dependencies:
    • logManager
    • Presenter
    • N number of Query classes

Example

export class GetAppMetricsTotalWholeTeamCompositeController {
  constructor(
    protected readonly logManager: LogManager,
    protected readonly queryOne: GetAppMetricsTotalWholeTeamQuery,
    protected readonly queryTwo: GetAppMetricsTotalWholeTeamQuery,
    protected readonly presenter: AppMetricsTotalWholeTeamPresenter
  ) {}

  async get(
    props: GetAppMetricsTotalsWholeTeamRequestDto
  ): Promise<GetAppMetricsTotalsWholeTeamResponseDto> {
    try {
     const [resultOne, resultTwo] = await Promise.all([
      this.queryOne.execute(props),
      this.queryTwo.execute(props)
    ])
      return this.presenter.toResponse({resultOne, resultTwo});
    } catch (error) {
      throw new Error(
        `GetAppMetricsTotalWholeTeamCompositeController.get encountered an error ${error}`
      );
    }
  }
}

Enabling a feature in the client

As we currently have partial support for the legacy codebase (under the /lib directory in the cube helper package), we must inject any new controllers into the pre-existing client class for Frontend enablement.

To achieve this, we’ve defined an interface to store new feature methods for the client to use:

interface IReforgedCubeHelperMethods {...}

This interface is then extended in the entry point interface for the cube helper package library. Under /lib/index.ts:

interface ICubeQueryHelper extends IReforgedCubeHelperMethods {...}

When implementing a new feature, ensure you expose it to the frontend by adding an appropriately named method to the Reforged methods interface and implementing it in the cube query helper class.

Example:

export class CubeQueryHelper implements ICubeQueryHelper {
 ...

  async getAppMetricsTotalsWholeTeam(
    params: GetAppMetricsTotalsWholeTeamRequestDto
  ): Promise<GetAppMetricsTotalsWholeTeamResponseDto> {
    try {
      const mapper = new AppMetricsTotalWholeTeamMapper();
      const service = new GetAppMetricsTotalWholeTeamService(
        this.logManager,
        this.adapter,
        mapper
      );
      const query = new GetAppMetricsTotalWholeTeamQuery(
        this.logManager,
        service
      );
      const controller = new GetAppMetricsTotalWholeTeamController(
        this.logManager,
        query,
        mapper
      );
      return await controller.get(params);
    } catch (error) {
      this.logManager.error(
        'TeamMetricsController.getAppMetricsTotalsWholeTeam encountered an error',
        error
      );
    }
  };

 ...
}

Dates

  • When working with Date, we should always be using UTC dates throughout the entirety of the feature
    • if we don’t, then the date will be subject to the user machine’s local time, which can cause issues
  • for example, use instead of functions like getDate(), we should use getUTCDate()

Testing Standards

  • With the exception of DTOs, every layer of the code base can be united tested.

Path structure

<component-location>/test/unit/

Naming Convention

<feature-name>|<entity-name>.<type>.test.ts

Requirements

  • Every component of a feature should have an accompanying unit test suite covering both happy and un-happy test cases.

Example:

describe('Given GetAppMetricsTotalWholeTeamService', () => {
  const mockLogManager = mock<LogManager>();
  const mockAdapter = mock<CubeAdapter>();
  const mockMapper = mock<AppMetricsTotalWholeTeamMapper>();

  const sut = new GetAppMetricsTotalWholeTeamService(
    mockLogManager,
    mockAdapter,
    mockMapper
  );

  afterEach(() => {
    jest.clearAllMocks();
  });
  describe('Given valid input', () => {
    it('Should return without error', async () => {
      mockAdapter.query.mockResolvedValueOnce([
        {
          [ITeamAnalyticsActivityV2Enum.TotalDuration]: '1',
          [ITeamAnalyticsActivityV2Enum.TotalCallDuration]: '1',
          [ITeamAnalyticsActivityV2Enum.UserCount]: '1',
        },
      ]);
      const entity = AppMetricsTotalWholeTeamEntity.create({
        timeInApp: 1,
        timeInCalls: 1,
      });
      mockMapper.toDomain.mockReturnValueOnce(entity);
      const result = await sut.execute({
        params: {
          startDate: '2024-01-01',
          endDate: '2024-01-01',
          teamId: '',
          userIds: [''],
        },
        config: { token: '' },
      });
      expect(result.equals(entity)).toEqual(true);
      expect(mockAdapter.query).toHaveBeenCalled();
      expect(mockMapper.toDomain).toHaveBeenCalled();
    });

    it('Should return error service throws error', async () => {
      mockAdapter.query.mockRejectedValueOnce('some error');
      await expect(
        sut.execute({
          params: {
            startDate: '2024-01-01',
            endDate: '2024-01-01',
            teamId: '',
            userIds: [''],
          },
          config: { token: '' },
        })
      ).rejects.toThrow();
    });
  });
});

Devops

Automated Semantic Release workflow

Motivation

Large teams working together on a project that requires a consistent release cycle creates friction if said release cycle is managed by humans. Humans are naturally fallible.

Using a consistent, standard commit message approach, an automated solution can be introduced that allows for semantic releases to happen without the need for human input (beyond merging the odd pull request).

Solution

Integrate one or more tools to enable automated semantic version releases as part of our CI/CD pipeline.

Proposed Tools

Getting started

To integrate this solution with an existing project, here's what you'll need:

  • Setup an npm access token added as a secret in your repository so the Github action can publish to NPM.
  • Add the following packages to your repository:
npm install --save-dev semantic-release husky @commitlint/{cli,config-conventional}

or

yarn add --dev semantic-release husky @commitlint/{cli,config-conventional}

  • Create or add a .npmrc file with the following command:

echo "access=public" >> .npmrc

  • Initialize husky and create a pre-commit hook:
npx husky init
echo "npx --no -- commitlint --edit \\$1" > .husky/commit-msg

  • Setup default configuration for semantic-release:
echo "export default { extends: ["@commitlint/config-conventional"] };" > commitlint.config.ts

  • Create a Github workflow file to handle releases on merge to main:
.github/workflows/release.yml:
name: Release
on:
  push:
    branches:
      - main ## or master

permissions:
  contents: read ## for checkout

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    permissions:
      contents: write ## to be able to publish a GitHub release
      issues: write ## to be able to comment on released issues
      pull-requests: write ## to be able to comment on released pull requests
      id-token: write ## to enable use of OIDC for npm provenance
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "lts/*"
      - name: Install dependencies
        run: npm clean-install
      - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
        run: npm audit signatures
      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx semantic-release

How it works

When a pull request is merged into main, the Github workflow Release action is triggered which invokes semantic-release. This traverses the commit history and compiles a release based on the commit convention.

Anatomy of a commit message

<type>(<scope>): <short summary>
  │       │             │
  │       │             └─⫸ Summary in present tense. Not capitalized. No period at the end.
  │       │
  │       └─⫸ Commit Scope(Optional): animations|bazel|benchpress|common|compiler|compiler-cli|core|
  │                          elements|forms|http|language-service|localize|platform-browser|
  │                          platform-browser-dynamic|platform-server|router|service-worker|
  │                          upgrade|zone.js|packaging|changelog|docs-infra|migrations|
  │                          devtools
  │
  └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test

Types

  • build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
  • ci: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
  • docs: Documentation only changes
  • feat: A new feature
  • fix: A bug fix
  • perf: A code change that improves performance
  • refactor: A code change that neither fixes a bug nor adds a feature
  • test: Adding missing tests or correcting existing tests

What will trigger a release

The following commit message patterns will trigger a release of their given scope:

  • Patch release/Fix Release (X.X.1)
fix(pencil): stop graphite breaking when too much pressure applied

  • Minor release/Feature Release (X.1.0)
feat(pencil): stop graphite breaking when too much pressure applied

  • Major release/Breaking Release (1.0.0)

(Note that the BREAKING CHANGE: token must be in the footer of the commit)

feat(pencil): stop graphite breaking when too much pressure applied

BREAKING CHANGE: The graphiteWidth option has been removed.
The default graphite width of 10mm is always used for performance reasons.

Roadmap

Feature Deadline Complete
Dependency container for dependency injection solution TBD yes/ not currently implemented
Code linting (https://www.npmjs.com/package/eslint-plugin-hexagonal-architecture) TBD
CLI tool for dev experience TBD
class validator @Sasha Mahalia 2024-04-05 yes
semantic-release
commitlint https://commitlint.js.org/guides/local-setup.html

Semantic Release

https://semantic-release.gitbook.io/semantic-release

https://commitlint.js.org/guides/getting-started.html

https://www.conventionalcommits.org/en/v1.0.0/

FAQ

Q. What is a top level domain entity?

  • A. A top level domain entity is the entity that returns data to the client. This is achieved via the use of a Controller and Mapper. This can be an Entity or and AggregateRoot

Q. How the heck do I write entity mappers for an aggregate root?

  • A. It depends. If you have an entity that is only referenced in the context of its parent aggregate root, you can omit the toResponse mapper method as it's not needed. However, if the given entity is also a top level domain entity then it does indeed need a complete mapper interface, as it will return a response interface to the client.

Q. What’s going on with these postgres operator errors?

  • A: Getting an error of type operator does not exist: character varying < timestamp with time zone or similar is a Postgres error that refers to having a mismatch between the operation a Cube query is trying to perform with the datatype that isn’t a timestamp. Converting the offending postgres datatype - ie “varchar” to “timestamp” will solve the issue.

Q. Do I add end-to-end and contract tests to the Cube Helper package?

  • A: Yes. However, due to issues in the past with E2E and contract tests being unreliable and dependent on real data from Cube, these tests are to only be run locally as part development work. These tests are located in the test/integration folder.

Q. What is a composite-controller?

  • A: A composite controller is a superset of a controller. Typically a controller has a one-to-one relationship with a query interface. This is to maintain separation of concerns and single responsibility. Where a composite controller comes in is when you have a use case that is more nuanced and requires composing an array of queries into a single presenter for the client to consume. Team App Metrics Refactor

Versions

Current Tags

VersionDownloads (Last 7 Days)Tag
17.4.075latest
13.0.0-alpha.51alpha

Version History

VersionDownloads (Last 7 Days)Published
17.4.075
17.3.062
17.2.257
17.2.134
17.2.04
17.1.14
17.1.04
17.0.02
16.1.05
16.0.14
16.0.01
15.0.11
15.0.01
14.0.11
14.0.02
13.0.0-alpha.51
13.12.01
13.0.0-alpha.41
13.0.0-alpha.31
13.0.0-alpha.21
13.0.0-alpha.11
13.11.01
13.10.01
13.9.01
13.8.11
13.8.01
13.7.01
13.6.01
13.5.01
13.4.01
13.3.01
13.2.01
13.1.01
13.0.91
13.0.81
13.0.71
13.0.61
13.0.51
13.0.41
13.0.31
13.0.21
13.0.11
13.0.01
12.1.41
12.0.0-alpha.71
12.1.31
12.1.21
12.1.11
12.0.0-alpha.61
12.0.0-alpha.51
12.0.0-alpha.41
12.1.01
12.0.11
12.0.01
12.0.0-alpha.31
12.0.0-alpha.21
12.0.0-alpha.11
11.41.2-alpha.61
11.41.2-alpha.51
11.41.2-alpha.41
11.41.2-alpha.31
11.41.2-alpha.21
11.41.2-alpha.11
11.41.11
11.41.01
11.40.81
11.40.71
11.40.61
11.40.41
11.40.31
11.40.21
11.40.11
11.40.01
11.39.41
11.39.31
11.39.21
11.39.11
11.39.01
11.38.01
11.37.21
11.37.01
11.36.221
11.36.211
11.36.201
11.36.191
11.36.181
11.36.171
11.36.151
11.36.141
11.36.131
11.36.121
11.36.111
11.36.101
11.36.91
11.36.81
11.36.71
11.36.61
11.36.311
11.36.11
11.36.01
11.35.91
11.35.61
11.35.51
11.35.41
11.35.31
11.35.21
11.35.11
11.34.11
11.34.01
11.33.51
11.33.31
11.33.21
11.33.11
11.31.11
11.30.21
11.30.11
11.30.01
11.28.31
11.28.21
11.28.11
11.26.030
11.25.01
11.23.131
11.23.121
11.23.111
11.23.101
11.23.91
11.23.81
11.23.71
11.23.61
11.23.51
11.23.41
11.23.31
11.23.21
11.23.11
11.23.01
11.22.191
11.22.181
11.22.171
11.22.141
11.22.131
11.22.121
11.22.111
11.22.101
11.22.91
11.22.81
11.22.61
11.22.51
11.22.31
11.22.21
11.22.11
11.22.01
11.21.271
11.21.261
11.21.231
11.21.221
11.21.211
11.21.191
11.21.171
11.21.151
11.21.131
11.21.121
11.21.111
11.21.101
11.21.81
11.21.71
11.21.61
11.21.51
11.21.31
11.21.21
11.21.11
11.20.11
11.19.11
11.17.31
11.17.21
11.17.11
11.17.01
11.16.31
11.16.21
11.16.11
11.16.01
11.15.01
11.14.11
11.14.01
11.13.11
11.13.01
11.12.101
11.12.91
11.12.81
11.12.71
11.12.61
11.12.51
11.12.41
11.12.31
11.12.21
11.12.11
11.12.01
11.11.41
11.11.31
11.11.21
11.11.11
11.11.01
11.10.31
11.10.21
11.10.11
11.10.01
11.9.11
11.9.02
11.8.61
11.8.51
11.8.41
11.8.31
11.8.21
11.8.11
11.8.01
11.7.41
11.7.31
11.7.21
11.7.11
11.7.01
11.6.11
11.6.01
11.5.01
11.4.01
11.3.91
11.3.81
11.3.51
11.3.41
11.3.31
11.3.21
11.3.11
11.2.21
11.2.11
11.2.01
11.1.01
11.0.11
11.0.01
10.1.41
10.1.31
10.1.21
10.1.10
10.1.01
10.0.31
10.0.21
10.0.11
10.0.01
9.0.11
8.1.11
8.1.01
8.0.11
8.0.01
7.0.51
7.0.21
7.0.11
7.0.01
6.0.151
6.0.141
6.0.131
6.0.121
6.0.101
6.0.91
6.0.81
6.0.71
6.0.51
6.0.41
6.0.31
6.0.21
6.0.11
6.0.01
5.9.71
5.9.61
5.9.51
5.9.41
5.9.31
5.9.01
5.8.31
5.8.21
5.8.11
5.8.01
5.6.01
5.5.01
5.4.01
5.3.030
5.2.31
5.2.01
5.1.31
5.1.21
5.1.11
5.1.01
5.0.22
5.0.02
4.0.141
4.0.132
4.0.121
4.0.91
4.0.81
4.0.71
4.0.61
4.0.51
4.0.41
4.0.31
4.0.21
4.0.01
3.4.21
3.4.11
3.4.01
3.3.21
3.3.11
3.3.01
3.2.11
3.2.01
3.1.11
3.0.31
3.0.21
3.0.11
3.0.01
2.1.11
2.1.01

Package Sidebar

Install

npm i @produce8/p8-cube-helper

Weekly Downloads

631

Version

17.4.0

License

MIT

Unpacked Size

12.3 MB

Total Files

6794

Last publish

Collaborators

  • atp8
  • gaffleck
  • brandon-kyle-bailey