mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: segment implementation in delta (#9148)
This is implementing the segments events for delta API. Previous version of delta API, we were just sending all of the segments. Now we will have `segment-updated` and `segment-removed `events coming to SDK.
This commit is contained in:
		
							parent
							
								
									4d582aac5a
								
							
						
					
					
						commit
						d993b1963a
					
				@ -41,7 +41,6 @@ import {
 | 
			
		||||
} from '../../internals';
 | 
			
		||||
import isEqual from 'lodash.isequal';
 | 
			
		||||
import { diff } from 'json-diff';
 | 
			
		||||
import type { DeltaHydrationEvent } from './delta/client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
const version = 2;
 | 
			
		||||
 | 
			
		||||
@ -193,8 +192,7 @@ export default class FeatureController extends Controller {
 | 
			
		||||
                    a.name.localeCompare(b.name),
 | 
			
		||||
                );
 | 
			
		||||
                if (delta?.events[0].type === 'hydration') {
 | 
			
		||||
                    const hydrationEvent: DeltaHydrationEvent =
 | 
			
		||||
                        delta?.events[0];
 | 
			
		||||
                    const hydrationEvent = delta?.events[0];
 | 
			
		||||
                    const sortedNewToggles = hydrationEvent.features.sort(
 | 
			
		||||
                        (a, b) => a.name.localeCompare(b.name),
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ import {
 | 
			
		||||
} from '../../../../test/e2e/helpers/test-helper';
 | 
			
		||||
import getLogger from '../../../../test/fixtures/no-logger';
 | 
			
		||||
import { DEFAULT_ENV } from '../../../util/constants';
 | 
			
		||||
import { DELTA_EVENT_TYPES } from './client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
let app: IUnleashTest;
 | 
			
		||||
let db: ITestDb;
 | 
			
		||||
@ -121,7 +122,7 @@ test('should return correct delta after feature created', async () => {
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        events: [
 | 
			
		||||
            {
 | 
			
		||||
                type: 'hydration',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.HYDRATION,
 | 
			
		||||
                features: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'base_feature',
 | 
			
		||||
@ -134,8 +135,6 @@ test('should return correct delta after feature created', async () => {
 | 
			
		||||
    await app.createFeature('new_feature');
 | 
			
		||||
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    //@ts-ignore
 | 
			
		||||
    await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
 | 
			
		||||
 | 
			
		||||
    const { body: deltaBody } = await app.request
 | 
			
		||||
        .get('/api/client/delta')
 | 
			
		||||
@ -145,13 +144,7 @@ test('should return correct delta after feature created', async () => {
 | 
			
		||||
    expect(deltaBody).toMatchObject({
 | 
			
		||||
        events: [
 | 
			
		||||
            {
 | 
			
		||||
                type: 'feature-updated',
 | 
			
		||||
                feature: {
 | 
			
		||||
                    name: 'new_feature',
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                type: 'feature-updated',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
                feature: {
 | 
			
		||||
                    name: 'new_feature',
 | 
			
		||||
                },
 | 
			
		||||
@ -161,7 +154,9 @@ test('should return correct delta after feature created', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const syncRevisions = async () => {
 | 
			
		||||
    await app.services.configurationRevisionService.updateMaxRevisionId();
 | 
			
		||||
    await app.services.configurationRevisionService.updateMaxRevisionId(false);
 | 
			
		||||
    //@ts-ignore
 | 
			
		||||
    await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
test('archived features should not be returned as updated', async () => {
 | 
			
		||||
@ -187,7 +182,6 @@ test('archived features should not be returned as updated', async () => {
 | 
			
		||||
    await app.archiveFeature('base_feature');
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    await app.createFeature('new_feature');
 | 
			
		||||
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    await app.getProjectFeatures('new_feature'); // TODO: this is silly, but events syncing and tests do not work nicely. this is basically a setTimeout
 | 
			
		||||
 | 
			
		||||
@ -199,11 +193,11 @@ test('archived features should not be returned as updated', async () => {
 | 
			
		||||
    expect(deltaBody).toMatchObject({
 | 
			
		||||
        events: [
 | 
			
		||||
            {
 | 
			
		||||
                type: 'feature-removed',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_REMOVED,
 | 
			
		||||
                featureName: 'base_feature',
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                type: 'feature-updated',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
                feature: {
 | 
			
		||||
                    name: 'new_feature',
 | 
			
		||||
                },
 | 
			
		||||
@ -211,3 +205,68 @@ test('archived features should not be returned as updated', async () => {
 | 
			
		||||
        ],
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should get segment updated and removed events', async () => {
 | 
			
		||||
    await app.createFeature('base_feature');
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    const { body, headers } = await app.request
 | 
			
		||||
        .get('/api/client/delta')
 | 
			
		||||
        .expect(200);
 | 
			
		||||
    const etag = headers.etag;
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        events: [
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.HYDRATION,
 | 
			
		||||
                features: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'base_feature',
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const { body: segmentBody } = await app.createSegment({
 | 
			
		||||
        name: 'my_segment_a',
 | 
			
		||||
        constraints: [],
 | 
			
		||||
    });
 | 
			
		||||
    // we need this, because revision service does not fire event for segment creation
 | 
			
		||||
    await app.createFeature('not_important1');
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    await app.updateSegment(segmentBody.id, {
 | 
			
		||||
        name: 'a',
 | 
			
		||||
        constraints: [],
 | 
			
		||||
    });
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
    await app.deleteSegment(segmentBody.id);
 | 
			
		||||
    // we need this, because revision service does not fire event for segment deletion
 | 
			
		||||
    await app.createFeature('not_important2');
 | 
			
		||||
    await syncRevisions();
 | 
			
		||||
 | 
			
		||||
    const { body: deltaBody } = await app.request
 | 
			
		||||
        .get('/api/client/delta')
 | 
			
		||||
        .set('If-None-Match', etag)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(deltaBody).toMatchObject({
 | 
			
		||||
        events: [
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.SEGMENT_UPDATED,
 | 
			
		||||
            },
 | 
			
		||||
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.SEGMENT_UPDATED,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                type: DELTA_EVENT_TYPES.SEGMENT_REMOVED,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ export type DeltaHydrationEvent = {
 | 
			
		||||
    eventId: number;
 | 
			
		||||
    type: 'hydration';
 | 
			
		||||
    features: ClientFeatureSchema[];
 | 
			
		||||
    segments: IClientSegment[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type DeltaEvent =
 | 
			
		||||
@ -50,14 +51,11 @@ export const isDeltaFeatureRemovedEvent = (
 | 
			
		||||
    return event.type === DELTA_EVENT_TYPES.FEATURE_REMOVED;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isDeltaSegmentUpdatedEvent = (
 | 
			
		||||
export const isDeltaSegmentEvent = (
 | 
			
		||||
    event: DeltaEvent,
 | 
			
		||||
): event is Extract<DeltaEvent, { type: 'segment-updated' }> => {
 | 
			
		||||
    return event.type === DELTA_EVENT_TYPES.SEGMENT_UPDATED;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isDeltaSegmentRemovedEvent = (
 | 
			
		||||
    event: DeltaEvent,
 | 
			
		||||
): event is Extract<DeltaEvent, { type: 'segment-removed' }> => {
 | 
			
		||||
    return event.type === DELTA_EVENT_TYPES.SEGMENT_REMOVED;
 | 
			
		||||
    return (
 | 
			
		||||
        event.type === DELTA_EVENT_TYPES.SEGMENT_UPDATED ||
 | 
			
		||||
        event.type === DELTA_EVENT_TYPES.SEGMENT_REMOVED
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,10 @@ import {
 | 
			
		||||
    filterEventsByQuery,
 | 
			
		||||
    filterHydrationEventByQuery,
 | 
			
		||||
} from './client-feature-toggle-delta';
 | 
			
		||||
import type { DeltaHydrationEvent } from './client-feature-toggle-delta-types';
 | 
			
		||||
import {
 | 
			
		||||
    DELTA_EVENT_TYPES,
 | 
			
		||||
    type DeltaHydrationEvent,
 | 
			
		||||
} from './client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
const mockAdd = (params): any => {
 | 
			
		||||
    const base = {
 | 
			
		||||
@ -25,12 +28,12 @@ test('revision equal to the base case returns only later revisions ', () => {
 | 
			
		||||
    const revisionList: DeltaEvent[] = [
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 2,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature4' }),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 3,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature5' }),
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
@ -40,12 +43,12 @@ test('revision equal to the base case returns only later revisions ', () => {
 | 
			
		||||
    expect(revisions).toEqual([
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 2,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature4' }),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 3,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature5' }),
 | 
			
		||||
        },
 | 
			
		||||
    ]);
 | 
			
		||||
@ -55,17 +58,17 @@ test('project filter removes features not in project and nameprefix', () => {
 | 
			
		||||
    const revisionList: DeltaEvent[] = [
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 1,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature1', project: 'project1' }),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 2,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'feature2', project: 'project2' }),
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 3,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
 | 
			
		||||
        },
 | 
			
		||||
    ];
 | 
			
		||||
@ -75,7 +78,7 @@ test('project filter removes features not in project and nameprefix', () => {
 | 
			
		||||
    expect(revisions).toEqual([
 | 
			
		||||
        {
 | 
			
		||||
            eventId: 3,
 | 
			
		||||
            type: 'feature-updated',
 | 
			
		||||
            type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
 | 
			
		||||
        },
 | 
			
		||||
    ]);
 | 
			
		||||
@ -85,6 +88,13 @@ test('project filter removes features not in project in hydration', () => {
 | 
			
		||||
    const revisionList: DeltaHydrationEvent = {
 | 
			
		||||
        eventId: 1,
 | 
			
		||||
        type: 'hydration',
 | 
			
		||||
        segments: [
 | 
			
		||||
            {
 | 
			
		||||
                name: 'test',
 | 
			
		||||
                constraints: [],
 | 
			
		||||
                id: 1,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        features: [
 | 
			
		||||
            mockAdd({ name: 'feature1', project: 'project1' }),
 | 
			
		||||
            mockAdd({ name: 'feature2', project: 'project2' }),
 | 
			
		||||
@ -101,6 +111,13 @@ test('project filter removes features not in project in hydration', () => {
 | 
			
		||||
    expect(revisions).toEqual({
 | 
			
		||||
        eventId: 1,
 | 
			
		||||
        type: 'hydration',
 | 
			
		||||
        segments: [
 | 
			
		||||
            {
 | 
			
		||||
                name: 'test',
 | 
			
		||||
                constraints: [],
 | 
			
		||||
                id: 1,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        features: [mockAdd({ name: 'myfeature2', project: 'project2' })],
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import type {
 | 
			
		||||
    IClientSegment,
 | 
			
		||||
    IEventStore,
 | 
			
		||||
    IFeatureToggleDeltaQuery,
 | 
			
		||||
    IFeatureToggleQuery,
 | 
			
		||||
@ -24,6 +23,7 @@ import {
 | 
			
		||||
    type DeltaHydrationEvent,
 | 
			
		||||
    isDeltaFeatureRemovedEvent,
 | 
			
		||||
    isDeltaFeatureUpdatedEvent,
 | 
			
		||||
    isDeltaSegmentEvent,
 | 
			
		||||
} from './client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
type EnvironmentRevisions = Record<string, DeltaCache>;
 | 
			
		||||
@ -58,7 +58,9 @@ export const filterEventsByQuery = (
 | 
			
		||||
 | 
			
		||||
    return targetedEvents.filter((revision) => {
 | 
			
		||||
        return (
 | 
			
		||||
            startsWithPrefix(revision) && (allProjects || isInProject(revision))
 | 
			
		||||
            isDeltaSegmentEvent(revision) ||
 | 
			
		||||
            (startsWithPrefix(revision) &&
 | 
			
		||||
                (allProjects || isInProject(revision)))
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
@ -69,11 +71,12 @@ export const filterHydrationEventByQuery = (
 | 
			
		||||
    namePrefix: string,
 | 
			
		||||
): DeltaHydrationEvent => {
 | 
			
		||||
    const allProjects = projects.includes('*');
 | 
			
		||||
    const { type, features, eventId } = event;
 | 
			
		||||
    const { type, features, eventId, segments } = event;
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        eventId,
 | 
			
		||||
        type,
 | 
			
		||||
        segments,
 | 
			
		||||
        features: features.filter((feature) => {
 | 
			
		||||
            return (
 | 
			
		||||
                feature.name.startsWith(namePrefix) &&
 | 
			
		||||
@ -88,8 +91,6 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
 | 
			
		||||
    private delta: EnvironmentRevisions = {};
 | 
			
		||||
 | 
			
		||||
    private segments: IClientSegment[];
 | 
			
		||||
 | 
			
		||||
    private eventStore: IEventStore;
 | 
			
		||||
 | 
			
		||||
    private currentRevisionId: number = 0;
 | 
			
		||||
@ -124,7 +125,6 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
        this.delta = {};
 | 
			
		||||
 | 
			
		||||
        this.initRevisionId();
 | 
			
		||||
        this.updateSegments();
 | 
			
		||||
        this.configurationRevisionService.on(
 | 
			
		||||
            UPDATE_REVISION,
 | 
			
		||||
            this.onUpdateRevisionEvent,
 | 
			
		||||
@ -151,10 +151,6 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
        if (!hasDelta) {
 | 
			
		||||
            await this.initEnvironmentDelta(environment);
 | 
			
		||||
        }
 | 
			
		||||
        const hasSegments = this.segments;
 | 
			
		||||
        if (!hasSegments) {
 | 
			
		||||
            await this.updateSegments();
 | 
			
		||||
        }
 | 
			
		||||
        if (requiredRevisionId >= this.currentRevisionId) {
 | 
			
		||||
            return undefined;
 | 
			
		||||
        }
 | 
			
		||||
@ -167,12 +163,7 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            const response: ClientFeaturesDeltaSchema = {
 | 
			
		||||
                events: [
 | 
			
		||||
                    {
 | 
			
		||||
                        ...filteredEvent,
 | 
			
		||||
                        segments: this.segments,
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
                events: [filteredEvent],
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            return Promise.resolve(response);
 | 
			
		||||
@ -202,7 +193,6 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
    public async onUpdateRevisionEvent() {
 | 
			
		||||
        if (this.flagResolver.isEnabled('deltaApi')) {
 | 
			
		||||
            await this.updateFeaturesDelta();
 | 
			
		||||
            await this.updateSegments();
 | 
			
		||||
            this.storeFootprint();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -246,21 +236,32 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
                project: event.project!,
 | 
			
		||||
            }));
 | 
			
		||||
 | 
			
		||||
        // TODO: implement single segment fetching
 | 
			
		||||
        // const segmentsUpdated = changeEvents
 | 
			
		||||
        //     .filter((event) => event.type === 'segment-updated')
 | 
			
		||||
        //     .map((event) => ({
 | 
			
		||||
        //         name: event.featureName!,
 | 
			
		||||
        //         project: event.project!,
 | 
			
		||||
        //     }));
 | 
			
		||||
        //
 | 
			
		||||
        // const segmentsRemoved = changeEvents
 | 
			
		||||
        //     .filter((event) => event.type === 'segment-deleted')
 | 
			
		||||
        //     .map((event) => ({
 | 
			
		||||
        //         name: event.featureName!,
 | 
			
		||||
        //         project: event.project!,
 | 
			
		||||
        //     }));
 | 
			
		||||
        //
 | 
			
		||||
        const segmentsUpdated = changeEvents
 | 
			
		||||
            .filter((event) =>
 | 
			
		||||
                ['segment-created', 'segment-updated'].includes(event.type),
 | 
			
		||||
            )
 | 
			
		||||
            .map((event) => event.data.id);
 | 
			
		||||
 | 
			
		||||
        const segmentsRemoved = changeEvents
 | 
			
		||||
            .filter((event) => event.type === 'segment-deleted')
 | 
			
		||||
            .map((event) => event.preData.id);
 | 
			
		||||
 | 
			
		||||
        const segments =
 | 
			
		||||
            await this.segmentReadModel.getAllForClient(segmentsUpdated);
 | 
			
		||||
 | 
			
		||||
        const segmentsUpdatedEvents: DeltaEvent[] = segments.map((segment) => ({
 | 
			
		||||
            eventId: latestRevision,
 | 
			
		||||
            type: DELTA_EVENT_TYPES.SEGMENT_UPDATED,
 | 
			
		||||
            segment,
 | 
			
		||||
        }));
 | 
			
		||||
 | 
			
		||||
        const segmentsRemovedEvents: DeltaEvent[] = segmentsRemoved.map(
 | 
			
		||||
            (segmentId) => ({
 | 
			
		||||
                eventId: latestRevision,
 | 
			
		||||
                type: DELTA_EVENT_TYPES.SEGMENT_REMOVED,
 | 
			
		||||
                segmentId,
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // TODO: we might want to only update the environments that had events changed for performance
 | 
			
		||||
        for (const environment of keys) {
 | 
			
		||||
@ -278,6 +279,8 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
            this.delta[environment].addEvents([
 | 
			
		||||
                ...featuresUpdatedEvents,
 | 
			
		||||
                ...featuresRemovedEvents,
 | 
			
		||||
                ...segmentsUpdatedEvents,
 | 
			
		||||
                ...segmentsRemovedEvents,
 | 
			
		||||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
        this.currentRevisionId = latestRevision;
 | 
			
		||||
@ -287,6 +290,9 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
        environment: string,
 | 
			
		||||
        toggles: string[],
 | 
			
		||||
    ): Promise<FeatureConfigurationDeltaClient[]> {
 | 
			
		||||
        if (toggles.length === 0) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
        return this.getClientFeatures({
 | 
			
		||||
            toggleNames: toggles,
 | 
			
		||||
            environment,
 | 
			
		||||
@ -298,6 +304,7 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
        const baseFeatures = await this.getClientFeatures({
 | 
			
		||||
            environment,
 | 
			
		||||
        });
 | 
			
		||||
        const baseSegments = await this.segmentReadModel.getAllForClient();
 | 
			
		||||
 | 
			
		||||
        this.currentRevisionId =
 | 
			
		||||
            await this.configurationRevisionService.getMaxRevisionId();
 | 
			
		||||
@ -306,6 +313,7 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
            eventId: this.currentRevisionId,
 | 
			
		||||
            type: DELTA_EVENT_TYPES.HYDRATION,
 | 
			
		||||
            features: baseFeatures,
 | 
			
		||||
            segments: baseSegments,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.storeFootprint();
 | 
			
		||||
@ -319,15 +327,9 @@ export class ClientFeatureToggleDelta {
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async updateSegments(): Promise<void> {
 | 
			
		||||
        this.segments = await this.segmentReadModel.getActiveForClient();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    storeFootprint() {
 | 
			
		||||
        try {
 | 
			
		||||
            const featuresMemory = this.getCacheSizeInBytes(this.delta);
 | 
			
		||||
            const segmentsMemory = this.getCacheSizeInBytes(this.segments);
 | 
			
		||||
            const memory = featuresMemory + segmentsMemory;
 | 
			
		||||
            const memory = this.getCacheSizeInBytes(this.delta);
 | 
			
		||||
            this.eventBus.emit(CLIENT_DELTA_MEMORY, { memory });
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            this.logger.error('Client delta footprint error', e);
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,8 @@
 | 
			
		||||
import { DeltaCache } from './delta-cache';
 | 
			
		||||
import type {
 | 
			
		||||
    DeltaEvent,
 | 
			
		||||
    DeltaHydrationEvent,
 | 
			
		||||
import {
 | 
			
		||||
    DELTA_EVENT_TYPES,
 | 
			
		||||
    type DeltaEvent,
 | 
			
		||||
    type DeltaHydrationEvent,
 | 
			
		||||
} from './client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
describe('RevisionCache', () => {
 | 
			
		||||
@ -55,6 +56,18 @@ describe('RevisionCache', () => {
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            type: 'hydration',
 | 
			
		||||
            segments: [
 | 
			
		||||
                {
 | 
			
		||||
                    id: 1,
 | 
			
		||||
                    name: 'update-segment',
 | 
			
		||||
                    constraints: [],
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    id: 2,
 | 
			
		||||
                    name: 'remove-segment',
 | 
			
		||||
                    constraints: [],
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        };
 | 
			
		||||
        const initialEvents: DeltaEvent[] = [
 | 
			
		||||
            {
 | 
			
		||||
@ -81,7 +94,7 @@ describe('RevisionCache', () => {
 | 
			
		||||
                    description: null,
 | 
			
		||||
                    impressionData: false,
 | 
			
		||||
                },
 | 
			
		||||
                type: 'feature-updated',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
            },
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
@ -90,10 +103,10 @@ describe('RevisionCache', () => {
 | 
			
		||||
        deltaCache.addEvents(initialEvents);
 | 
			
		||||
 | 
			
		||||
        // Add a new revision to trigger changeBase
 | 
			
		||||
        deltaCache.addEvents([
 | 
			
		||||
        const addedEvents: DeltaEvent[] = [
 | 
			
		||||
            {
 | 
			
		||||
                eventId: 3,
 | 
			
		||||
                type: 'feature-updated',
 | 
			
		||||
                type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
			
		||||
                feature: {
 | 
			
		||||
                    name: 'another-feature-flag',
 | 
			
		||||
                    type: 'release',
 | 
			
		||||
@ -122,12 +135,37 @@ describe('RevisionCache', () => {
 | 
			
		||||
                featureName: 'test-flag',
 | 
			
		||||
                project: 'default',
 | 
			
		||||
            },
 | 
			
		||||
        ]);
 | 
			
		||||
            {
 | 
			
		||||
                eventId: 5,
 | 
			
		||||
                type: 'segment-updated',
 | 
			
		||||
                segment: {
 | 
			
		||||
                    id: 1,
 | 
			
		||||
                    name: 'update-segment-new',
 | 
			
		||||
                    constraints: [],
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                eventId: 6,
 | 
			
		||||
                type: 'segment-removed',
 | 
			
		||||
                segmentId: 2,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                eventId: 7,
 | 
			
		||||
                type: 'segment-updated',
 | 
			
		||||
                segment: {
 | 
			
		||||
                    id: 3,
 | 
			
		||||
                    name: 'new-segment',
 | 
			
		||||
                    constraints: [],
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        ];
 | 
			
		||||
        deltaCache.addEvents(addedEvents);
 | 
			
		||||
 | 
			
		||||
        const events = deltaCache.getEvents();
 | 
			
		||||
 | 
			
		||||
        // Check that the base has been changed and merged correctly
 | 
			
		||||
        expect(events.length).toBe(2);
 | 
			
		||||
        expect(events.length).toBe(maxLength);
 | 
			
		||||
        expect(events).toEqual(addedEvents.slice(-2));
 | 
			
		||||
 | 
			
		||||
        const hydrationEvent = deltaCache.getHydrationEvent();
 | 
			
		||||
        expect(hydrationEvent.features).toHaveLength(2);
 | 
			
		||||
@ -137,5 +175,11 @@ describe('RevisionCache', () => {
 | 
			
		||||
                expect.objectContaining({ name: 'another-feature-flag' }),
 | 
			
		||||
            ]),
 | 
			
		||||
        );
 | 
			
		||||
        expect(hydrationEvent.segments).toEqual(
 | 
			
		||||
            expect.arrayContaining([
 | 
			
		||||
                expect.objectContaining({ name: 'update-segment-new', id: 1 }),
 | 
			
		||||
                expect.objectContaining({ name: 'new-segment' }),
 | 
			
		||||
            ]),
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -4,14 +4,6 @@ import {
 | 
			
		||||
    type DeltaHydrationEvent,
 | 
			
		||||
} from './client-feature-toggle-delta-types';
 | 
			
		||||
 | 
			
		||||
const mergeWithoutDuplicates = (arr1: any[], arr2: any[]) => {
 | 
			
		||||
    const map = new Map();
 | 
			
		||||
    arr1.concat(arr2).forEach((item) => {
 | 
			
		||||
        map.set(item.name, item);
 | 
			
		||||
    });
 | 
			
		||||
    return Array.from(map.values());
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class DeltaCache {
 | 
			
		||||
    private events: DeltaEvent[] = [];
 | 
			
		||||
    private maxLength: number;
 | 
			
		||||
@ -27,7 +19,7 @@ export class DeltaCache {
 | 
			
		||||
 | 
			
		||||
        this.updateHydrationEvent(events);
 | 
			
		||||
        while (this.events.length > this.maxLength) {
 | 
			
		||||
            this.events.splice(1, 1);
 | 
			
		||||
            this.events.shift();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -62,15 +54,23 @@ export class DeltaCache {
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
                case DELTA_EVENT_TYPES.SEGMENT_UPDATED: {
 | 
			
		||||
                    // TODO: segments do not exist in this scope, need to do it in different location
 | 
			
		||||
                    const segmentToUpdate = this.hydrationEvent.segments.find(
 | 
			
		||||
                        (segment) => segment.id === appliedEvent.segment.id,
 | 
			
		||||
                    );
 | 
			
		||||
                    if (segmentToUpdate) {
 | 
			
		||||
                        Object.assign(segmentToUpdate, appliedEvent.segment);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        this.hydrationEvent.segments.push(appliedEvent.segment);
 | 
			
		||||
                    }
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
                case DELTA_EVENT_TYPES.SEGMENT_REMOVED: {
 | 
			
		||||
                    // TODO: segments do not exist in this scope, need to do it in different location
 | 
			
		||||
                    this.hydrationEvent.segments =
 | 
			
		||||
                        this.hydrationEvent.segments.filter(
 | 
			
		||||
                            (segment) => segment.id !== appliedEvent.segmentId,
 | 
			
		||||
                        );
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
                default:
 | 
			
		||||
                // TODO: something is seriously wrong
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,8 @@ import {
 | 
			
		||||
    type IBaseEvent,
 | 
			
		||||
    type IEvent,
 | 
			
		||||
    type IEventType,
 | 
			
		||||
    SEGMENT_CREATED,
 | 
			
		||||
    SEGMENT_DELETED,
 | 
			
		||||
    SEGMENT_UPDATED,
 | 
			
		||||
} from '../../types/events';
 | 
			
		||||
import type { Logger, LogProvider } from '../../logger';
 | 
			
		||||
@ -214,6 +216,8 @@ class EventStore implements IEventStore {
 | 
			
		||||
                        SEGMENT_UPDATED,
 | 
			
		||||
                        FEATURE_IMPORT,
 | 
			
		||||
                        FEATURES_IMPORTED,
 | 
			
		||||
                        SEGMENT_CREATED,
 | 
			
		||||
                        SEGMENT_DELETED,
 | 
			
		||||
                    ]),
 | 
			
		||||
            )
 | 
			
		||||
            .orderBy('id', 'asc');
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,5 @@
 | 
			
		||||
import type { Logger } from '../../logger';
 | 
			
		||||
import type {
 | 
			
		||||
    IEvent,
 | 
			
		||||
    IEventStore,
 | 
			
		||||
    IFlagResolver,
 | 
			
		||||
    IUnleashConfig,
 | 
			
		||||
@ -60,7 +59,7 @@ export default class ConfigurationRevisionService extends EventEmitter {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async updateMaxRevisionId(): Promise<number> {
 | 
			
		||||
    async updateMaxRevisionId(emit: boolean = true): Promise<number> {
 | 
			
		||||
        if (this.flagResolver.isEnabled('disableUpdateMaxRevisionId')) {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
@ -74,16 +73,14 @@ export default class ConfigurationRevisionService extends EventEmitter {
 | 
			
		||||
                revisionId,
 | 
			
		||||
            );
 | 
			
		||||
            this.revisionId = revisionId;
 | 
			
		||||
            if (emit) {
 | 
			
		||||
                this.emit(UPDATE_REVISION, revisionId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.revisionId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
 | 
			
		||||
        return this.eventStore.getRevisionRange(start, end);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    destroy(): void {
 | 
			
		||||
        ConfigurationRevisionService.instance?.removeAllListeners();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -7,8 +7,7 @@ import type { ISegmentReadModel } from './segment-read-model-type';
 | 
			
		||||
 | 
			
		||||
export class FakeSegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
    constructor(private segments: ISegment[] = []) {}
 | 
			
		||||
 | 
			
		||||
    async getAll(): Promise<ISegment[]> {
 | 
			
		||||
    async getAll(ids?: number[]): Promise<ISegment[]> {
 | 
			
		||||
        return this.segments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -23,4 +22,8 @@ export class FakeSegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
    async getActiveForClient(): Promise<IClientSegment[]> {
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAllForClient(ids?: number[]): Promise<IClientSegment[]> {
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -5,8 +5,9 @@ import type {
 | 
			
		||||
} from '../../types';
 | 
			
		||||
 | 
			
		||||
export interface ISegmentReadModel {
 | 
			
		||||
    getAll(): Promise<ISegment[]>;
 | 
			
		||||
    getAll(ids?: number[]): Promise<ISegment[]>;
 | 
			
		||||
    getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]>;
 | 
			
		||||
    getActive(): Promise<ISegment[]>;
 | 
			
		||||
    getActiveForClient(): Promise<IClientSegment[]>;
 | 
			
		||||
    getAllForClient(ids?: number[]): Promise<IClientSegment[]>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -61,12 +61,17 @@ export class SegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAll(): Promise<ISegment[]> {
 | 
			
		||||
        const rows: ISegmentRow[] = await this.db
 | 
			
		||||
    async getAll(ids?: number[]): Promise<ISegment[]> {
 | 
			
		||||
        let query = this.db
 | 
			
		||||
            .select(this.prefixColumns())
 | 
			
		||||
            .from('segments')
 | 
			
		||||
            .orderBy('segments.name', 'asc');
 | 
			
		||||
 | 
			
		||||
        if (ids && ids.length > 0) {
 | 
			
		||||
            query = query.whereIn('id', ids);
 | 
			
		||||
        }
 | 
			
		||||
        const rows = await query;
 | 
			
		||||
 | 
			
		||||
        return rows.map(this.mapRow);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -82,7 +87,7 @@ export class SegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getActive(): Promise<ISegment[]> {
 | 
			
		||||
        const rows: ISegmentRow[] = await this.db
 | 
			
		||||
        const query = this.db
 | 
			
		||||
            .distinct(this.prefixColumns())
 | 
			
		||||
            .from('segments')
 | 
			
		||||
            .orderBy('name', 'asc')
 | 
			
		||||
@ -91,7 +96,7 @@ export class SegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
                'feature_strategy_segment.segment_id',
 | 
			
		||||
                'segments.id',
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
        const rows: ISegmentRow[] = await query;
 | 
			
		||||
        return rows.map(this.mapRow);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -104,4 +109,14 @@ export class SegmentReadModel implements ISegmentReadModel {
 | 
			
		||||
            constraints: segments.constraints,
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAllForClient(ids?: number[]): Promise<IClientSegment[]> {
 | 
			
		||||
        const fullSegments = await this.getAll(ids);
 | 
			
		||||
 | 
			
		||||
        return fullSegments.map((segments) => ({
 | 
			
		||||
            id: segments.id,
 | 
			
		||||
            name: segments.name,
 | 
			
		||||
            constraints: segments.constraints,
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -117,6 +117,15 @@ export interface IUnleashHttpAPI {
 | 
			
		||||
    getRecordedEvents(): supertest.Test;
 | 
			
		||||
 | 
			
		||||
    createSegment(postData: object, expectStatusCode?: number): supertest.Test;
 | 
			
		||||
    deleteSegment(
 | 
			
		||||
        segmentId: number,
 | 
			
		||||
        expectedResponseCode?: number,
 | 
			
		||||
    ): supertest.Test;
 | 
			
		||||
    updateSegment(
 | 
			
		||||
        segmentId: number,
 | 
			
		||||
        postData: object,
 | 
			
		||||
        expectStatusCode?: number,
 | 
			
		||||
    ): supertest.Test;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function httpApis(
 | 
			
		||||
@ -288,7 +297,25 @@ function httpApis(
 | 
			
		||||
                .set('Content-Type', 'application/json')
 | 
			
		||||
                .expect(expectedResponseCode);
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        deleteSegment(
 | 
			
		||||
            segmentId: number,
 | 
			
		||||
            expectedResponseCode = 204,
 | 
			
		||||
        ): supertest.Test {
 | 
			
		||||
            return request
 | 
			
		||||
                .delete(`/api/admin/segments/${segmentId}`)
 | 
			
		||||
                .set('Content-Type', 'application/json')
 | 
			
		||||
                .expect(expectedResponseCode);
 | 
			
		||||
        },
 | 
			
		||||
        updateSegment(
 | 
			
		||||
            segmentId: number,
 | 
			
		||||
            postData: object,
 | 
			
		||||
            expectStatusCode = 204,
 | 
			
		||||
        ): supertest.Test {
 | 
			
		||||
            return request
 | 
			
		||||
                .put(`/api/admin/segments/${segmentId}`)
 | 
			
		||||
                .send(postData)
 | 
			
		||||
                .expect(expectStatusCode);
 | 
			
		||||
        },
 | 
			
		||||
        getRecordedEvents(
 | 
			
		||||
            project: string | null = null,
 | 
			
		||||
            expectedResponseCode: number = 200,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user