mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: delta rework (#9133)
We are changing how the Delta API works, as discussed: 1. We have removed the `updated` and `removed` arrays and now keep everything in the `events` array. 2. We decided to keep the hydration cache separate from the events array internally. Since the hydration cache has a special structure and may contain not just one feature but potentially 1,000 features, it behaved differently, requiring a lot of special logic to handle it. 3. Implemented `nameprefix` filtering, which we were missing before. Things still to implement: 1. Segment hydration and updates to it.
This commit is contained in:
		
							parent
							
								
									4bbff0c554
								
							
						
					
					
						commit
						280710f22a
					
				@ -41,6 +41,7 @@ import {
 | 
				
			|||||||
} from '../../internals';
 | 
					} from '../../internals';
 | 
				
			||||||
import isEqual from 'lodash.isequal';
 | 
					import isEqual from 'lodash.isequal';
 | 
				
			||||||
import { diff } from 'json-diff';
 | 
					import { diff } from 'json-diff';
 | 
				
			||||||
 | 
					import type { DeltaHydrationEvent } from './delta/client-feature-toggle-delta-types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const version = 2;
 | 
					const version = 2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -191,22 +192,34 @@ export default class FeatureController extends Controller {
 | 
				
			|||||||
                const sortedToggles = features.sort((a, b) =>
 | 
					                const sortedToggles = features.sort((a, b) =>
 | 
				
			||||||
                    a.name.localeCompare(b.name),
 | 
					                    a.name.localeCompare(b.name),
 | 
				
			||||||
                );
 | 
					                );
 | 
				
			||||||
                const sortedNewToggles = delta?.updated.sort((a, b) =>
 | 
					                if (delta?.events[0].type === 'hydration') {
 | 
				
			||||||
                    a.name.localeCompare(b.name),
 | 
					                    const hydrationEvent: DeltaHydrationEvent =
 | 
				
			||||||
                );
 | 
					                        delta?.events[0];
 | 
				
			||||||
 | 
					                    const sortedNewToggles = hydrationEvent.features.sort(
 | 
				
			||||||
 | 
					                        (a, b) => a.name.localeCompare(b.name),
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if (
 | 
					                    if (
 | 
				
			||||||
                    !this.deepEqualIgnoreOrder(sortedToggles, sortedNewToggles)
 | 
					                        !this.deepEqualIgnoreOrder(
 | 
				
			||||||
                ) {
 | 
					                            sortedToggles,
 | 
				
			||||||
                    this.logger.warn(
 | 
					                            sortedNewToggles,
 | 
				
			||||||
                        `old features and new features are different. Old count ${
 | 
					                        )
 | 
				
			||||||
                            features.length
 | 
					                    ) {
 | 
				
			||||||
                        }, new count ${delta?.updated.length}, query ${JSON.stringify(query)},
 | 
					                        this.logger.warn(
 | 
				
			||||||
 | 
					                            `old features and new features are different. Old count ${
 | 
				
			||||||
 | 
					                                features.length
 | 
				
			||||||
 | 
					                            }, new count ${hydrationEvent.features.length}, query ${JSON.stringify(query)},
 | 
				
			||||||
                        diff ${JSON.stringify(
 | 
					                        diff ${JSON.stringify(
 | 
				
			||||||
                            diff(sortedToggles, sortedNewToggles),
 | 
					                            diff(sortedToggles, sortedNewToggles),
 | 
				
			||||||
                        )}`,
 | 
					                        )}`,
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    this.logger.warn(
 | 
				
			||||||
 | 
					                        `Delta diff should have only hydration event, query ${JSON.stringify(query)}`,
 | 
				
			||||||
                    );
 | 
					                    );
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                this.storeFootprint();
 | 
					                this.storeFootprint();
 | 
				
			||||||
            } catch (e) {
 | 
					            } catch (e) {
 | 
				
			||||||
                this.logger.error('Delta diff failed', e);
 | 
					                this.logger.error('Delta diff failed', e);
 | 
				
			||||||
 | 
				
			|||||||
@ -37,7 +37,11 @@ const setupFeatures = async (
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            name: 'default',
 | 
					            name: 'default',
 | 
				
			||||||
            constraints: [
 | 
					            constraints: [
 | 
				
			||||||
                { contextName: 'userId', operator: 'IN', values: ['123'] },
 | 
					                {
 | 
				
			||||||
 | 
					                    contextName: 'userId',
 | 
				
			||||||
 | 
					                    operator: 'IN',
 | 
				
			||||||
 | 
					                    values: ['123'],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
            parameters: {},
 | 
					            parameters: {},
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
@ -88,17 +92,19 @@ test('should match with /api/client/delta', async () => {
 | 
				
			|||||||
        .expect('Content-Type', /json/)
 | 
					        .expect('Content-Type', /json/)
 | 
				
			||||||
        .expect(200);
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(body.features).toMatchObject(deltaBody.updated);
 | 
					    expect(body.features).toMatchObject(deltaBody.events[0].features);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('should get 304 if asked for latest revision', async () => {
 | 
					test('should get 304 if asked for latest revision', async () => {
 | 
				
			||||||
    await setupFeatures(db, app);
 | 
					    await setupFeatures(db, app);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { body } = await app.request.get('/api/client/delta').expect(200);
 | 
					    const { body, headers } = await app.request
 | 
				
			||||||
    const currentRevisionId = body.revisionId;
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					    const etag = headers.etag;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await app.request
 | 
					    await app.request
 | 
				
			||||||
        .set('If-None-Match', `"${currentRevisionId}"`)
 | 
					        .set('If-None-Match', etag)
 | 
				
			||||||
        .get('/api/client/delta')
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
        .expect(304);
 | 
					        .expect(304);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
@ -106,13 +112,21 @@ test('should get 304 if asked for latest revision', async () => {
 | 
				
			|||||||
test('should return correct delta after feature created', async () => {
 | 
					test('should return correct delta after feature created', async () => {
 | 
				
			||||||
    await app.createFeature('base_feature');
 | 
					    await app.createFeature('base_feature');
 | 
				
			||||||
    await syncRevisions();
 | 
					    await syncRevisions();
 | 
				
			||||||
    const { body } = await app.request.get('/api/client/delta').expect(200);
 | 
					    const { body, headers } = await app.request
 | 
				
			||||||
    const currentRevisionId = body.revisionId;
 | 
					        .set('If-None-Match', null)
 | 
				
			||||||
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					    const etag = headers.etag;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(body).toMatchObject({
 | 
					    expect(body).toMatchObject({
 | 
				
			||||||
        updated: [
 | 
					        events: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                name: 'base_feature',
 | 
					                type: 'hydration',
 | 
				
			||||||
 | 
					                features: [
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        name: 'base_feature',
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
@ -120,16 +134,27 @@ test('should return correct delta after feature created', async () => {
 | 
				
			|||||||
    await app.createFeature('new_feature');
 | 
					    await app.createFeature('new_feature');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await syncRevisions();
 | 
					    await syncRevisions();
 | 
				
			||||||
 | 
					    //@ts-ignore
 | 
				
			||||||
 | 
					    await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { body: deltaBody } = await app.request
 | 
					    const { body: deltaBody } = await app.request
 | 
				
			||||||
        .get('/api/client/delta')
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
        .set('If-None-Match', `"${currentRevisionId}"`)
 | 
					        .set('If-None-Match', etag)
 | 
				
			||||||
        .expect(200);
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(deltaBody).toMatchObject({
 | 
					    expect(deltaBody).toMatchObject({
 | 
				
			||||||
        updated: [
 | 
					        events: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                name: 'new_feature',
 | 
					                type: 'feature-updated',
 | 
				
			||||||
 | 
					                feature: {
 | 
				
			||||||
 | 
					                    name: 'new_feature',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                type: 'feature-updated',
 | 
				
			||||||
 | 
					                feature: {
 | 
				
			||||||
 | 
					                    name: 'new_feature',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
@ -137,40 +162,52 @@ test('should return correct delta after feature created', async () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const syncRevisions = async () => {
 | 
					const syncRevisions = async () => {
 | 
				
			||||||
    await app.services.configurationRevisionService.updateMaxRevisionId();
 | 
					    await app.services.configurationRevisionService.updateMaxRevisionId();
 | 
				
			||||||
    // @ts-ignore
 | 
					 | 
				
			||||||
    await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('archived features should not be returned as updated', async () => {
 | 
					test('archived features should not be returned as updated', async () => {
 | 
				
			||||||
    await app.createFeature('base_feature');
 | 
					    await app.createFeature('base_feature');
 | 
				
			||||||
    await syncRevisions();
 | 
					    await syncRevisions();
 | 
				
			||||||
    const { body } = await app.request.get('/api/client/delta').expect(200);
 | 
					    const { body, headers } = await app.request
 | 
				
			||||||
    const currentRevisionId = body.revisionId;
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					    const etag = headers.etag;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(body).toMatchObject({
 | 
					    expect(body).toMatchObject({
 | 
				
			||||||
        updated: [
 | 
					        events: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                name: 'base_feature',
 | 
					                features: [
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        name: 'base_feature',
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await app.archiveFeature('base_feature');
 | 
					    await app.archiveFeature('base_feature');
 | 
				
			||||||
 | 
					    await syncRevisions();
 | 
				
			||||||
    await app.createFeature('new_feature');
 | 
					    await app.createFeature('new_feature');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await syncRevisions();
 | 
					    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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { body: deltaBody } = await app.request
 | 
					    const { body: deltaBody } = await app.request
 | 
				
			||||||
        .get('/api/client/delta')
 | 
					        .get('/api/client/delta')
 | 
				
			||||||
        .set('If-None-Match', `"${currentRevisionId}"`)
 | 
					        .set('If-None-Match', etag)
 | 
				
			||||||
        .expect(200);
 | 
					        .expect(200);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(deltaBody).toMatchObject({
 | 
					    expect(deltaBody).toMatchObject({
 | 
				
			||||||
        updated: [
 | 
					        events: [
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                name: 'new_feature',
 | 
					                type: 'feature-removed',
 | 
				
			||||||
 | 
					                featureName: 'base_feature',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                type: 'feature-updated',
 | 
				
			||||||
 | 
					                feature: {
 | 
				
			||||||
 | 
					                    name: 'new_feature',
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        removed: ['base_feature'],
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -103,15 +103,16 @@ export default class ClientFeatureToggleDeltaController extends Controller {
 | 
				
			|||||||
            res.end();
 | 
					            res.end();
 | 
				
			||||||
            return;
 | 
					            return;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        const lastEventId =
 | 
				
			||||||
        if (changedFeatures.revisionId === currentSdkRevisionId) {
 | 
					            changedFeatures.events[changedFeatures.events.length - 1].eventId;
 | 
				
			||||||
 | 
					        if (lastEventId === currentSdkRevisionId) {
 | 
				
			||||||
            res.status(304);
 | 
					            res.status(304);
 | 
				
			||||||
            res.getHeaderNames().forEach((header) => res.removeHeader(header));
 | 
					            res.getHeaderNames().forEach((header) => res.removeHeader(header));
 | 
				
			||||||
            res.end();
 | 
					            res.end();
 | 
				
			||||||
            return;
 | 
					            return;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        res.setHeader('ETag', `"${changedFeatures.revisionId}"`);
 | 
					        res.setHeader('ETag', `"${lastEventId}"`);
 | 
				
			||||||
        this.openApiService.respondWithValidation(
 | 
					        this.openApiService.respondWithValidation(
 | 
				
			||||||
            200,
 | 
					            200,
 | 
				
			||||||
            res,
 | 
					            res,
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,63 @@
 | 
				
			|||||||
 | 
					import type { ClientFeatureSchema } from '../../../openapi';
 | 
				
			||||||
 | 
					import type { IClientSegment } from '../../../types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DeltaHydrationEvent = {
 | 
				
			||||||
 | 
					    eventId: number;
 | 
				
			||||||
 | 
					    type: 'hydration';
 | 
				
			||||||
 | 
					    features: ClientFeatureSchema[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type DeltaEvent =
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					          eventId: number;
 | 
				
			||||||
 | 
					          type: 'feature-updated';
 | 
				
			||||||
 | 
					          feature: ClientFeatureSchema;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					          eventId: number;
 | 
				
			||||||
 | 
					          type: 'feature-removed';
 | 
				
			||||||
 | 
					          featureName: string;
 | 
				
			||||||
 | 
					          project: string;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					          eventId: number;
 | 
				
			||||||
 | 
					          type: 'segment-updated';
 | 
				
			||||||
 | 
					          segment: IClientSegment;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    | {
 | 
				
			||||||
 | 
					          eventId: number;
 | 
				
			||||||
 | 
					          type: 'segment-removed';
 | 
				
			||||||
 | 
					          segmentId: number;
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DELTA_EVENT_TYPES = {
 | 
				
			||||||
 | 
					    FEATURE_UPDATED: 'feature-updated',
 | 
				
			||||||
 | 
					    FEATURE_REMOVED: 'feature-removed',
 | 
				
			||||||
 | 
					    SEGMENT_UPDATED: 'segment-updated',
 | 
				
			||||||
 | 
					    SEGMENT_REMOVED: 'segment-removed',
 | 
				
			||||||
 | 
					    HYDRATION: 'hydration',
 | 
				
			||||||
 | 
					} as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isDeltaFeatureUpdatedEvent = (
 | 
				
			||||||
 | 
					    event: DeltaEvent,
 | 
				
			||||||
 | 
					): event is Extract<DeltaEvent, { type: 'feature-updated' }> => {
 | 
				
			||||||
 | 
					    return event.type === DELTA_EVENT_TYPES.FEATURE_UPDATED;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isDeltaFeatureRemovedEvent = (
 | 
				
			||||||
 | 
					    event: DeltaEvent,
 | 
				
			||||||
 | 
					): event is Extract<DeltaEvent, { type: 'feature-removed' }> => {
 | 
				
			||||||
 | 
					    return event.type === DELTA_EVENT_TYPES.FEATURE_REMOVED;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isDeltaSegmentUpdatedEvent = (
 | 
				
			||||||
 | 
					    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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -1,4 +1,9 @@
 | 
				
			|||||||
import { calculateRequiredClientRevision } from './client-feature-toggle-delta';
 | 
					import {
 | 
				
			||||||
 | 
					    type DeltaEvent,
 | 
				
			||||||
 | 
					    filterEventsByQuery,
 | 
				
			||||||
 | 
					    filterHydrationEventByQuery,
 | 
				
			||||||
 | 
					} from './client-feature-toggle-delta';
 | 
				
			||||||
 | 
					import type { DeltaHydrationEvent } from './client-feature-toggle-delta-types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mockAdd = (params): any => {
 | 
					const mockAdd = (params): any => {
 | 
				
			||||||
    const base = {
 | 
					    const base = {
 | 
				
			||||||
@ -16,166 +21,86 @@ const mockAdd = (params): any => {
 | 
				
			|||||||
    return { ...base, ...params };
 | 
					    return { ...base, ...params };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('compresses multiple revisions to a single update', () => {
 | 
					 | 
				
			||||||
    const revisionList = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 1,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ type: 'release' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 2,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ type: 'test' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const revisions = calculateRequiredClientRevision(revisionList, 0, [
 | 
					 | 
				
			||||||
        'default',
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    expect(revisions).toEqual({
 | 
					 | 
				
			||||||
        revisionId: 2,
 | 
					 | 
				
			||||||
        updated: [mockAdd({ type: 'test' })],
 | 
					 | 
				
			||||||
        removed: [],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
test('revision that adds, removes then adds again does not end up with the remove', () => {
 | 
					 | 
				
			||||||
    const revisionList = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 1,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ name: 'some-toggle' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 2,
 | 
					 | 
				
			||||||
            updated: [],
 | 
					 | 
				
			||||||
            removed: [
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    name: 'some-toggle',
 | 
					 | 
				
			||||||
                    project: 'default',
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 3,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ name: 'some-toggle' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const revisions = calculateRequiredClientRevision(revisionList, 0, [
 | 
					 | 
				
			||||||
        'default',
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    expect(revisions).toEqual({
 | 
					 | 
				
			||||||
        revisionId: 3,
 | 
					 | 
				
			||||||
        updated: [mockAdd({ name: 'some-toggle' })],
 | 
					 | 
				
			||||||
        removed: [],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
test('revision that removes, adds then removes again does not end up with the remove', () => {
 | 
					 | 
				
			||||||
    const revisionList = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 1,
 | 
					 | 
				
			||||||
            updated: [],
 | 
					 | 
				
			||||||
            removed: [
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    name: 'some-toggle',
 | 
					 | 
				
			||||||
                    project: 'default',
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 2,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ name: 'some-toggle' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 3,
 | 
					 | 
				
			||||||
            updated: [],
 | 
					 | 
				
			||||||
            removed: [
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    name: 'some-toggle',
 | 
					 | 
				
			||||||
                    project: 'default',
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const revisions = calculateRequiredClientRevision(revisionList, 0, [
 | 
					 | 
				
			||||||
        'default',
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    expect(revisions).toEqual({
 | 
					 | 
				
			||||||
        revisionId: 3,
 | 
					 | 
				
			||||||
        updated: [],
 | 
					 | 
				
			||||||
        removed: [
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                name: 'some-toggle',
 | 
					 | 
				
			||||||
                project: 'default',
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
test('revision equal to the base case returns only later revisions ', () => {
 | 
					test('revision equal to the base case returns only later revisions ', () => {
 | 
				
			||||||
    const revisionList = [
 | 
					    const revisionList: DeltaEvent[] = [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            revisionId: 1,
 | 
					            eventId: 2,
 | 
				
			||||||
            updated: [
 | 
					            type: 'feature-updated',
 | 
				
			||||||
                mockAdd({ name: 'feature1' }),
 | 
					            feature: mockAdd({ name: 'feature4' }),
 | 
				
			||||||
                mockAdd({ name: 'feature2' }),
 | 
					 | 
				
			||||||
                mockAdd({ name: 'feature3' }),
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            revisionId: 2,
 | 
					            eventId: 3,
 | 
				
			||||||
            updated: [mockAdd({ name: 'feature4' })],
 | 
					            type: 'feature-updated',
 | 
				
			||||||
            removed: [],
 | 
					            feature: mockAdd({ name: 'feature5' }),
 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            revisionId: 3,
 | 
					 | 
				
			||||||
            updated: [mockAdd({ name: 'feature5' })],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const revisions = calculateRequiredClientRevision(revisionList, 1, [
 | 
					    const revisions = filterEventsByQuery(revisionList, 1, ['default'], '');
 | 
				
			||||||
        'default',
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(revisions).toEqual({
 | 
					    expect(revisions).toEqual([
 | 
				
			||||||
        revisionId: 3,
 | 
					 | 
				
			||||||
        updated: [mockAdd({ name: 'feature4' }), mockAdd({ name: 'feature5' })],
 | 
					 | 
				
			||||||
        removed: [],
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
test('project filter removes features not in project', () => {
 | 
					 | 
				
			||||||
    const revisionList = [
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            revisionId: 1,
 | 
					            eventId: 2,
 | 
				
			||||||
            updated: [mockAdd({ name: 'feature1', project: 'project1' })],
 | 
					            type: 'feature-updated',
 | 
				
			||||||
            removed: [],
 | 
					            feature: mockAdd({ name: 'feature4' }),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            revisionId: 2,
 | 
					            eventId: 3,
 | 
				
			||||||
            updated: [mockAdd({ name: 'feature2', project: 'project2' })],
 | 
					            type: 'feature-updated',
 | 
				
			||||||
            removed: [],
 | 
					            feature: mockAdd({ name: 'feature5' }),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test('project filter removes features not in project and nameprefix', () => {
 | 
				
			||||||
 | 
					    const revisionList: DeltaEvent[] = [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            eventId: 1,
 | 
				
			||||||
 | 
					            type: 'feature-updated',
 | 
				
			||||||
 | 
					            feature: mockAdd({ name: 'feature1', project: 'project1' }),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            eventId: 2,
 | 
				
			||||||
 | 
					            type: 'feature-updated',
 | 
				
			||||||
 | 
					            feature: mockAdd({ name: 'feature2', project: 'project2' }),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            eventId: 3,
 | 
				
			||||||
 | 
					            type: 'feature-updated',
 | 
				
			||||||
 | 
					            feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const revisions = calculateRequiredClientRevision(revisionList, 0, [
 | 
					    const revisions = filterEventsByQuery(revisionList, 0, ['project1'], 'ff');
 | 
				
			||||||
        'project1',
 | 
					
 | 
				
			||||||
 | 
					    expect(revisions).toEqual([
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            eventId: 3,
 | 
				
			||||||
 | 
					            type: 'feature-updated',
 | 
				
			||||||
 | 
					            feature: mockAdd({ name: 'ffeature1', project: 'project1' }),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    ]);
 | 
					    ]);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test('project filter removes features not in project in hydration', () => {
 | 
				
			||||||
 | 
					    const revisionList: DeltaHydrationEvent = {
 | 
				
			||||||
 | 
					        eventId: 1,
 | 
				
			||||||
 | 
					        type: 'hydration',
 | 
				
			||||||
 | 
					        features: [
 | 
				
			||||||
 | 
					            mockAdd({ name: 'feature1', project: 'project1' }),
 | 
				
			||||||
 | 
					            mockAdd({ name: 'feature2', project: 'project2' }),
 | 
				
			||||||
 | 
					            mockAdd({ name: 'myfeature2', project: 'project2' }),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const revisions = filterHydrationEventByQuery(
 | 
				
			||||||
 | 
					        revisionList,
 | 
				
			||||||
 | 
					        ['project2'],
 | 
				
			||||||
 | 
					        'my',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(revisions).toEqual({
 | 
					    expect(revisions).toEqual({
 | 
				
			||||||
        revisionId: 2,
 | 
					        eventId: 1,
 | 
				
			||||||
        updated: [mockAdd({ name: 'feature1', project: 'project1' })],
 | 
					        type: 'hydration',
 | 
				
			||||||
        removed: [],
 | 
					        features: [mockAdd({ name: 'myfeature2', project: 'project2' })],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -9,7 +9,7 @@ import type {
 | 
				
			|||||||
} from '../../../types';
 | 
					} from '../../../types';
 | 
				
			||||||
import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
 | 
					import type ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service';
 | 
				
			||||||
import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
 | 
					import { UPDATE_REVISION } from '../../feature-toggle/configuration-revision-service';
 | 
				
			||||||
import { RevisionDelta } from './revision-delta';
 | 
					import { DeltaCache } from './delta-cache';
 | 
				
			||||||
import type {
 | 
					import type {
 | 
				
			||||||
    FeatureConfigurationDeltaClient,
 | 
					    FeatureConfigurationDeltaClient,
 | 
				
			||||||
    IClientFeatureToggleDeltaReadModel,
 | 
					    IClientFeatureToggleDeltaReadModel,
 | 
				
			||||||
@ -18,92 +18,75 @@ import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
 | 
				
			|||||||
import type EventEmitter from 'events';
 | 
					import type EventEmitter from 'events';
 | 
				
			||||||
import type { Logger } from '../../../logger';
 | 
					import type { Logger } from '../../../logger';
 | 
				
			||||||
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
 | 
					import type { ClientFeaturesDeltaSchema } from '../../../openapi';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    DELTA_EVENT_TYPES,
 | 
				
			||||||
 | 
					    type DeltaEvent,
 | 
				
			||||||
 | 
					    type DeltaHydrationEvent,
 | 
				
			||||||
 | 
					    isDeltaFeatureRemovedEvent,
 | 
				
			||||||
 | 
					    isDeltaFeatureUpdatedEvent,
 | 
				
			||||||
 | 
					} from './client-feature-toggle-delta-types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type DeletedFeature = {
 | 
					type EnvironmentRevisions = Record<string, DeltaCache>;
 | 
				
			||||||
    name: string;
 | 
					 | 
				
			||||||
    project: string;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type RevisionDeltaEntry = {
 | 
					export const filterEventsByQuery = (
 | 
				
			||||||
    updated: FeatureConfigurationDeltaClient[];
 | 
					    events: DeltaEvent[],
 | 
				
			||||||
    revisionId: number;
 | 
					 | 
				
			||||||
    removed: DeletedFeature[];
 | 
					 | 
				
			||||||
    segments: IClientSegment[];
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type Revision = {
 | 
					 | 
				
			||||||
    revisionId: number;
 | 
					 | 
				
			||||||
    updated: any[];
 | 
					 | 
				
			||||||
    removed: DeletedFeature[];
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Revisions = Record<string, RevisionDelta>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const applyRevision = (first: Revision, last: Revision): Revision => {
 | 
					 | 
				
			||||||
    const updatedMap = new Map(
 | 
					 | 
				
			||||||
        [...first.updated, ...last.updated].map((feature) => [
 | 
					 | 
				
			||||||
            feature.name,
 | 
					 | 
				
			||||||
            feature,
 | 
					 | 
				
			||||||
        ]),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    const removedMap = new Map(
 | 
					 | 
				
			||||||
        [...first.removed, ...last.removed].map((feature) => [
 | 
					 | 
				
			||||||
            feature.name,
 | 
					 | 
				
			||||||
            feature,
 | 
					 | 
				
			||||||
        ]),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const feature of last.updated) {
 | 
					 | 
				
			||||||
        removedMap.delete(feature.name);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const feature of last.removed) {
 | 
					 | 
				
			||||||
        updatedMap.delete(feature.name);
 | 
					 | 
				
			||||||
        removedMap.set(feature.name, feature);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
        revisionId: last.revisionId,
 | 
					 | 
				
			||||||
        updated: Array.from(updatedMap.values()),
 | 
					 | 
				
			||||||
        removed: Array.from(removedMap.values()),
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const filterRevisionByProject = (
 | 
					 | 
				
			||||||
    revision: Revision,
 | 
					 | 
				
			||||||
    projects: string[],
 | 
					 | 
				
			||||||
): Revision => {
 | 
					 | 
				
			||||||
    const updated = revision.updated.filter(
 | 
					 | 
				
			||||||
        (feature) =>
 | 
					 | 
				
			||||||
            projects.includes('*') || projects.includes(feature.project),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    const removed = revision.removed.filter(
 | 
					 | 
				
			||||||
        (feature) =>
 | 
					 | 
				
			||||||
            projects.includes('*') || projects.includes(feature.project),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return { ...revision, updated, removed };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const calculateRequiredClientRevision = (
 | 
					 | 
				
			||||||
    revisions: Revision[],
 | 
					 | 
				
			||||||
    requiredRevisionId: number,
 | 
					    requiredRevisionId: number,
 | 
				
			||||||
    projects: string[],
 | 
					    projects: string[],
 | 
				
			||||||
 | 
					    namePrefix: string,
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
    const targetedRevisions = revisions.filter(
 | 
					    const targetedEvents = events.filter(
 | 
				
			||||||
        (revision) => revision.revisionId > requiredRevisionId,
 | 
					        (revision) => revision.eventId > requiredRevisionId,
 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    const projectFeatureRevisions = targetedRevisions.map((revision) =>
 | 
					 | 
				
			||||||
        filterRevisionByProject(revision, projects),
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					    const allProjects = projects.includes('*');
 | 
				
			||||||
 | 
					    const startsWithPrefix = (revision: DeltaEvent) => {
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            (isDeltaFeatureUpdatedEvent(revision) &&
 | 
				
			||||||
 | 
					                revision.feature.name.startsWith(namePrefix)) ||
 | 
				
			||||||
 | 
					            (isDeltaFeatureRemovedEvent(revision) &&
 | 
				
			||||||
 | 
					                revision.featureName.startsWith(namePrefix))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return projectFeatureRevisions.reduce(applyRevision);
 | 
					    const isInProject = (revision: DeltaEvent) => {
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            (isDeltaFeatureUpdatedEvent(revision) &&
 | 
				
			||||||
 | 
					                projects.includes(revision.feature.project!)) ||
 | 
				
			||||||
 | 
					            (isDeltaFeatureRemovedEvent(revision) &&
 | 
				
			||||||
 | 
					                projects.includes(revision.project))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return targetedEvents.filter((revision) => {
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            startsWithPrefix(revision) && (allProjects || isInProject(revision))
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const filterHydrationEventByQuery = (
 | 
				
			||||||
 | 
					    event: DeltaHydrationEvent,
 | 
				
			||||||
 | 
					    projects: string[],
 | 
				
			||||||
 | 
					    namePrefix: string,
 | 
				
			||||||
 | 
					): DeltaHydrationEvent => {
 | 
				
			||||||
 | 
					    const allProjects = projects.includes('*');
 | 
				
			||||||
 | 
					    const { type, features, eventId } = event;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        eventId,
 | 
				
			||||||
 | 
					        type,
 | 
				
			||||||
 | 
					        features: features.filter((feature) => {
 | 
				
			||||||
 | 
					            return (
 | 
				
			||||||
 | 
					                feature.name.startsWith(namePrefix) &&
 | 
				
			||||||
 | 
					                (allProjects || projects.includes(feature.project!))
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class ClientFeatureToggleDelta {
 | 
					export class ClientFeatureToggleDelta {
 | 
				
			||||||
    private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
 | 
					    private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private delta: Revisions = {};
 | 
					    private delta: EnvironmentRevisions = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private segments: IClientSegment[];
 | 
					    private segments: IClientSegment[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -159,7 +142,8 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
    ): Promise<ClientFeaturesDeltaSchema | undefined> {
 | 
					    ): Promise<ClientFeaturesDeltaSchema | undefined> {
 | 
				
			||||||
        const projects = query.project ? query.project : ['*'];
 | 
					        const projects = query.project ? query.project : ['*'];
 | 
				
			||||||
        const environment = query.environment ? query.environment : 'default';
 | 
					        const environment = query.environment ? query.environment : 'default';
 | 
				
			||||||
        // TODO: filter by tags, what is namePrefix? anything else?
 | 
					        const namePrefix = query.namePrefix ? query.namePrefix : '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const requiredRevisionId = sdkRevisionId || 0;
 | 
					        const requiredRevisionId = sdkRevisionId || 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const hasDelta = this.delta[environment] !== undefined;
 | 
					        const hasDelta = this.delta[environment] !== undefined;
 | 
				
			||||||
@ -171,26 +155,48 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
        if (!hasSegments) {
 | 
					        if (!hasSegments) {
 | 
				
			||||||
            await this.updateSegments();
 | 
					            await this.updateSegments();
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (requiredRevisionId >= this.currentRevisionId) {
 | 
					        if (requiredRevisionId >= this.currentRevisionId) {
 | 
				
			||||||
            return undefined;
 | 
					            return undefined;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        if (requiredRevisionId === 0) {
 | 
				
			||||||
 | 
					            const hydrationEvent = this.delta[environment].getHydrationEvent();
 | 
				
			||||||
 | 
					            const filteredEvent = filterHydrationEventByQuery(
 | 
				
			||||||
 | 
					                hydrationEvent,
 | 
				
			||||||
 | 
					                projects,
 | 
				
			||||||
 | 
					                namePrefix,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const environmentRevisions = this.delta[environment].getRevisions();
 | 
					            const response: ClientFeaturesDeltaSchema = {
 | 
				
			||||||
 | 
					                events: [
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        ...filteredEvent,
 | 
				
			||||||
 | 
					                        segments: this.segments,
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const compressedRevision = calculateRequiredClientRevision(
 | 
					            return Promise.resolve(response);
 | 
				
			||||||
            environmentRevisions,
 | 
					        } else {
 | 
				
			||||||
            requiredRevisionId,
 | 
					            const environmentEvents = this.delta[environment].getEvents();
 | 
				
			||||||
            projects,
 | 
					            const events = filterEventsByQuery(
 | 
				
			||||||
        );
 | 
					                environmentEvents,
 | 
				
			||||||
 | 
					                requiredRevisionId,
 | 
				
			||||||
 | 
					                projects,
 | 
				
			||||||
 | 
					                namePrefix,
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const revisionResponse: ClientFeaturesDeltaSchema = {
 | 
					            const response: ClientFeaturesDeltaSchema = {
 | 
				
			||||||
            ...compressedRevision,
 | 
					                events: events.map((event) => {
 | 
				
			||||||
            segments: this.segments,
 | 
					                    if (event.type === 'feature-removed') {
 | 
				
			||||||
            removed: compressedRevision.removed.map((feature) => feature.name),
 | 
					                        const { project, ...rest } = event;
 | 
				
			||||||
        };
 | 
					                        return rest;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    return event;
 | 
				
			||||||
 | 
					                }),
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return Promise.resolve(revisionResponse);
 | 
					            return Promise.resolve(response);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async onUpdateRevisionEvent() {
 | 
					    public async onUpdateRevisionEvent() {
 | 
				
			||||||
@ -220,7 +226,7 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
            latestRevision,
 | 
					            latestRevision,
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const changedToggles = [
 | 
					        const featuresUpdated = [
 | 
				
			||||||
            ...new Set(
 | 
					            ...new Set(
 | 
				
			||||||
                changeEvents
 | 
					                changeEvents
 | 
				
			||||||
                    .filter((event) => event.featureName)
 | 
					                    .filter((event) => event.featureName)
 | 
				
			||||||
@ -230,25 +236,49 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const removed = changeEvents
 | 
					        const featuresRemovedEvents: DeltaEvent[] = changeEvents
 | 
				
			||||||
            .filter((event) => event.featureName && event.project)
 | 
					            .filter((event) => event.featureName && event.project)
 | 
				
			||||||
            .filter((event) => event.type === 'feature-archived')
 | 
					            .filter((event) => event.type === 'feature-archived')
 | 
				
			||||||
            .map((event) => ({
 | 
					            .map((event) => ({
 | 
				
			||||||
                name: event.featureName!,
 | 
					                eventId: latestRevision,
 | 
				
			||||||
 | 
					                type: DELTA_EVENT_TYPES.FEATURE_REMOVED,
 | 
				
			||||||
 | 
					                featureName: event.featureName!,
 | 
				
			||||||
                project: event.project!,
 | 
					                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!,
 | 
				
			||||||
 | 
					        //     }));
 | 
				
			||||||
 | 
					        //
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // TODO: we might want to only update the environments that had events changed for performance
 | 
					        // TODO: we might want to only update the environments that had events changed for performance
 | 
				
			||||||
        for (const environment of keys) {
 | 
					        for (const environment of keys) {
 | 
				
			||||||
            const newToggles = await this.getChangedToggles(
 | 
					            const newToggles = await this.getChangedToggles(
 | 
				
			||||||
                environment,
 | 
					                environment,
 | 
				
			||||||
                changedToggles,
 | 
					                featuresUpdated,
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
            this.delta[environment].addRevision({
 | 
					            const featuresUpdatedEvents: DeltaEvent[] = newToggles.map(
 | 
				
			||||||
                updated: newToggles,
 | 
					                (toggle) => ({
 | 
				
			||||||
                revisionId: latestRevision,
 | 
					                    eventId: latestRevision,
 | 
				
			||||||
                removed,
 | 
					                    type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
 | 
				
			||||||
            });
 | 
					                    feature: toggle,
 | 
				
			||||||
 | 
					                }),
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					            this.delta[environment].addEvents([
 | 
				
			||||||
 | 
					                ...featuresUpdatedEvents,
 | 
				
			||||||
 | 
					                ...featuresRemovedEvents,
 | 
				
			||||||
 | 
					            ]);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        this.currentRevisionId = latestRevision;
 | 
					        this.currentRevisionId = latestRevision;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -257,11 +287,10 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
        environment: string,
 | 
					        environment: string,
 | 
				
			||||||
        toggles: string[],
 | 
					        toggles: string[],
 | 
				
			||||||
    ): Promise<FeatureConfigurationDeltaClient[]> {
 | 
					    ): Promise<FeatureConfigurationDeltaClient[]> {
 | 
				
			||||||
        const foundToggles = await this.getClientFeatures({
 | 
					        return this.getClientFeatures({
 | 
				
			||||||
            toggleNames: toggles,
 | 
					            toggleNames: toggles,
 | 
				
			||||||
            environment,
 | 
					            environment,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        return foundToggles;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async initEnvironmentDelta(environment: string) {
 | 
					    public async initEnvironmentDelta(environment: string) {
 | 
				
			||||||
@ -273,14 +302,11 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
        this.currentRevisionId =
 | 
					        this.currentRevisionId =
 | 
				
			||||||
            await this.configurationRevisionService.getMaxRevisionId();
 | 
					            await this.configurationRevisionService.getMaxRevisionId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const delta = new RevisionDelta([
 | 
					        this.delta[environment] = new DeltaCache({
 | 
				
			||||||
            {
 | 
					            eventId: this.currentRevisionId,
 | 
				
			||||||
                revisionId: this.currentRevisionId,
 | 
					            type: DELTA_EVENT_TYPES.HYDRATION,
 | 
				
			||||||
                updated: baseFeatures,
 | 
					            features: baseFeatures,
 | 
				
			||||||
                removed: [],
 | 
					        });
 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ]);
 | 
					 | 
				
			||||||
        this.delta[environment] = delta;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.storeFootprint();
 | 
					        this.storeFootprint();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -313,3 +339,5 @@ export class ClientFeatureToggleDelta {
 | 
				
			|||||||
        return Buffer.byteLength(jsonString, 'utf8');
 | 
					        return Buffer.byteLength(jsonString, 'utf8');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type { DeltaEvent };
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,141 @@
 | 
				
			|||||||
 | 
					import { DeltaCache } from './delta-cache';
 | 
				
			||||||
 | 
					import type {
 | 
				
			||||||
 | 
					    DeltaEvent,
 | 
				
			||||||
 | 
					    DeltaHydrationEvent,
 | 
				
			||||||
 | 
					} from './client-feature-toggle-delta-types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('RevisionCache', () => {
 | 
				
			||||||
 | 
					    it('should always update the hydration event and remove event when over limit', () => {
 | 
				
			||||||
 | 
					        const baseEvent: DeltaHydrationEvent = {
 | 
				
			||||||
 | 
					            eventId: 1,
 | 
				
			||||||
 | 
					            features: [
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    name: 'test-flag',
 | 
				
			||||||
 | 
					                    type: 'release',
 | 
				
			||||||
 | 
					                    enabled: false,
 | 
				
			||||||
 | 
					                    project: 'default',
 | 
				
			||||||
 | 
					                    stale: false,
 | 
				
			||||||
 | 
					                    strategies: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            name: 'flexibleRollout',
 | 
				
			||||||
 | 
					                            constraints: [],
 | 
				
			||||||
 | 
					                            parameters: {
 | 
				
			||||||
 | 
					                                groupId: 'test-flag',
 | 
				
			||||||
 | 
					                                rollout: '100',
 | 
				
			||||||
 | 
					                                stickiness: 'default',
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            variants: [],
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    variants: [],
 | 
				
			||||||
 | 
					                    description: null,
 | 
				
			||||||
 | 
					                    impressionData: false,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    name: 'my-feature-flag',
 | 
				
			||||||
 | 
					                    type: 'release',
 | 
				
			||||||
 | 
					                    enabled: true,
 | 
				
			||||||
 | 
					                    project: 'default',
 | 
				
			||||||
 | 
					                    stale: false,
 | 
				
			||||||
 | 
					                    strategies: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            name: 'flexibleRollout',
 | 
				
			||||||
 | 
					                            constraints: [],
 | 
				
			||||||
 | 
					                            parameters: {
 | 
				
			||||||
 | 
					                                groupId: 'my-feature-flag',
 | 
				
			||||||
 | 
					                                rollout: '100',
 | 
				
			||||||
 | 
					                                stickiness: 'default',
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            variants: [],
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    variants: [],
 | 
				
			||||||
 | 
					                    description: null,
 | 
				
			||||||
 | 
					                    impressionData: false,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            type: 'hydration',
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        const initialEvents: DeltaEvent[] = [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                eventId: 2,
 | 
				
			||||||
 | 
					                feature: {
 | 
				
			||||||
 | 
					                    name: 'my-feature-flag',
 | 
				
			||||||
 | 
					                    type: 'release',
 | 
				
			||||||
 | 
					                    enabled: true,
 | 
				
			||||||
 | 
					                    project: 'default',
 | 
				
			||||||
 | 
					                    stale: false,
 | 
				
			||||||
 | 
					                    strategies: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            name: 'flexibleRollout',
 | 
				
			||||||
 | 
					                            constraints: [],
 | 
				
			||||||
 | 
					                            parameters: {
 | 
				
			||||||
 | 
					                                groupId: 'my-feature-flag',
 | 
				
			||||||
 | 
					                                rollout: '100',
 | 
				
			||||||
 | 
					                                stickiness: 'default',
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            variants: [],
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    variants: [],
 | 
				
			||||||
 | 
					                    description: null,
 | 
				
			||||||
 | 
					                    impressionData: false,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                type: 'feature-updated',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const maxLength = 2;
 | 
				
			||||||
 | 
					        const deltaCache = new DeltaCache(baseEvent, maxLength);
 | 
				
			||||||
 | 
					        deltaCache.addEvents(initialEvents);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add a new revision to trigger changeBase
 | 
				
			||||||
 | 
					        deltaCache.addEvents([
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                eventId: 3,
 | 
				
			||||||
 | 
					                type: 'feature-updated',
 | 
				
			||||||
 | 
					                feature: {
 | 
				
			||||||
 | 
					                    name: 'another-feature-flag',
 | 
				
			||||||
 | 
					                    type: 'release',
 | 
				
			||||||
 | 
					                    enabled: true,
 | 
				
			||||||
 | 
					                    project: 'default',
 | 
				
			||||||
 | 
					                    stale: false,
 | 
				
			||||||
 | 
					                    strategies: [
 | 
				
			||||||
 | 
					                        {
 | 
				
			||||||
 | 
					                            name: 'flexibleRollout',
 | 
				
			||||||
 | 
					                            constraints: [],
 | 
				
			||||||
 | 
					                            parameters: {
 | 
				
			||||||
 | 
					                                groupId: 'another-feature-flag',
 | 
				
			||||||
 | 
					                                rollout: '100',
 | 
				
			||||||
 | 
					                                stickiness: 'default',
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                    variants: [],
 | 
				
			||||||
 | 
					                    description: null,
 | 
				
			||||||
 | 
					                    impressionData: false,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                eventId: 4,
 | 
				
			||||||
 | 
					                type: 'feature-removed',
 | 
				
			||||||
 | 
					                featureName: 'test-flag',
 | 
				
			||||||
 | 
					                project: 'default',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const events = deltaCache.getEvents();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check that the base has been changed and merged correctly
 | 
				
			||||||
 | 
					        expect(events.length).toBe(2);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const hydrationEvent = deltaCache.getHydrationEvent();
 | 
				
			||||||
 | 
					        expect(hydrationEvent.features).toHaveLength(2);
 | 
				
			||||||
 | 
					        expect(hydrationEvent.features).toEqual(
 | 
				
			||||||
 | 
					            expect.arrayContaining([
 | 
				
			||||||
 | 
					                expect.objectContaining({ name: 'my-feature-flag' }),
 | 
				
			||||||
 | 
					                expect.objectContaining({ name: 'another-feature-flag' }),
 | 
				
			||||||
 | 
					            ]),
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										77
									
								
								src/lib/features/client-feature-toggles/delta/delta-cache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								src/lib/features/client-feature-toggles/delta/delta-cache.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					import type { DeltaEvent } from './client-feature-toggle-delta';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    DELTA_EVENT_TYPES,
 | 
				
			||||||
 | 
					    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;
 | 
				
			||||||
 | 
					    private hydrationEvent: DeltaHydrationEvent;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(hydrationEvent: DeltaHydrationEvent, maxLength: number = 20) {
 | 
				
			||||||
 | 
					        this.hydrationEvent = hydrationEvent;
 | 
				
			||||||
 | 
					        this.maxLength = maxLength;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public addEvents(events: DeltaEvent[]): void {
 | 
				
			||||||
 | 
					        this.events = [...this.events, ...events];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.updateHydrationEvent(events);
 | 
				
			||||||
 | 
					        while (this.events.length > this.maxLength) {
 | 
				
			||||||
 | 
					            this.events.splice(1, 1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public getEvents(): DeltaEvent[] {
 | 
				
			||||||
 | 
					        return this.events;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public getHydrationEvent(): DeltaHydrationEvent {
 | 
				
			||||||
 | 
					        return this.hydrationEvent;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private updateHydrationEvent(events: DeltaEvent[]): void {
 | 
				
			||||||
 | 
					        for (const appliedEvent of events) {
 | 
				
			||||||
 | 
					            switch (appliedEvent.type) {
 | 
				
			||||||
 | 
					                case DELTA_EVENT_TYPES.FEATURE_UPDATED: {
 | 
				
			||||||
 | 
					                    const featureToUpdate = this.hydrationEvent.features.find(
 | 
				
			||||||
 | 
					                        (feature) => feature.name === appliedEvent.feature.name,
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                    if (featureToUpdate) {
 | 
				
			||||||
 | 
					                        Object.assign(featureToUpdate, appliedEvent.feature);
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        this.hydrationEvent.features.push(appliedEvent.feature);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                case DELTA_EVENT_TYPES.FEATURE_REMOVED: {
 | 
				
			||||||
 | 
					                    this.hydrationEvent.features =
 | 
				
			||||||
 | 
					                        this.hydrationEvent.features.filter(
 | 
				
			||||||
 | 
					                            (feature) =>
 | 
				
			||||||
 | 
					                                feature.name !== appliedEvent.featureName,
 | 
				
			||||||
 | 
					                        );
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                case DELTA_EVENT_TYPES.SEGMENT_UPDATED: {
 | 
				
			||||||
 | 
					                    // TODO: segments do not exist in this scope, need to do it in different location
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                case DELTA_EVENT_TYPES.SEGMENT_REMOVED: {
 | 
				
			||||||
 | 
					                    // TODO: segments do not exist in this scope, need to do it in different location
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                default:
 | 
				
			||||||
 | 
					                // TODO: something is seriously wrong
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,110 +0,0 @@
 | 
				
			|||||||
import { RevisionDelta } from './revision-delta';
 | 
					 | 
				
			||||||
import type { Revision } from './client-feature-toggle-delta';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
describe('RevisionCache', () => {
 | 
					 | 
				
			||||||
    it('should create a new base when trying to add a new revision at the max limit', () => {
 | 
					 | 
				
			||||||
        const initialRevisions: Revision[] = [
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                revisionId: 1,
 | 
					 | 
				
			||||||
                updated: [
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        name: 'test-flag',
 | 
					 | 
				
			||||||
                        type: 'release',
 | 
					 | 
				
			||||||
                        enabled: false,
 | 
					 | 
				
			||||||
                        project: 'default',
 | 
					 | 
				
			||||||
                        stale: false,
 | 
					 | 
				
			||||||
                        strategies: [
 | 
					 | 
				
			||||||
                            {
 | 
					 | 
				
			||||||
                                name: 'flexibleRollout',
 | 
					 | 
				
			||||||
                                constraints: [],
 | 
					 | 
				
			||||||
                                parameters: {
 | 
					 | 
				
			||||||
                                    groupId: 'test-flag',
 | 
					 | 
				
			||||||
                                    rollout: '100',
 | 
					 | 
				
			||||||
                                    stickiness: 'default',
 | 
					 | 
				
			||||||
                                },
 | 
					 | 
				
			||||||
                                variants: [],
 | 
					 | 
				
			||||||
                            },
 | 
					 | 
				
			||||||
                        ],
 | 
					 | 
				
			||||||
                        variants: [],
 | 
					 | 
				
			||||||
                        description: null,
 | 
					 | 
				
			||||||
                        impressionData: false,
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
                removed: [],
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                revisionId: 2,
 | 
					 | 
				
			||||||
                updated: [
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        name: 'my-feature-flag',
 | 
					 | 
				
			||||||
                        type: 'release',
 | 
					 | 
				
			||||||
                        enabled: true,
 | 
					 | 
				
			||||||
                        project: 'default',
 | 
					 | 
				
			||||||
                        stale: false,
 | 
					 | 
				
			||||||
                        strategies: [
 | 
					 | 
				
			||||||
                            {
 | 
					 | 
				
			||||||
                                name: 'flexibleRollout',
 | 
					 | 
				
			||||||
                                constraints: [],
 | 
					 | 
				
			||||||
                                parameters: {
 | 
					 | 
				
			||||||
                                    groupId: 'my-feature-flag',
 | 
					 | 
				
			||||||
                                    rollout: '100',
 | 
					 | 
				
			||||||
                                    stickiness: 'default',
 | 
					 | 
				
			||||||
                                },
 | 
					 | 
				
			||||||
                                variants: [],
 | 
					 | 
				
			||||||
                            },
 | 
					 | 
				
			||||||
                        ],
 | 
					 | 
				
			||||||
                        variants: [],
 | 
					 | 
				
			||||||
                        description: null,
 | 
					 | 
				
			||||||
                        impressionData: false,
 | 
					 | 
				
			||||||
                    },
 | 
					 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
                removed: [],
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const maxLength = 2;
 | 
					 | 
				
			||||||
        const deltaCache = new RevisionDelta(initialRevisions, maxLength);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Add a new revision to trigger changeBase
 | 
					 | 
				
			||||||
        deltaCache.addRevision({
 | 
					 | 
				
			||||||
            revisionId: 3,
 | 
					 | 
				
			||||||
            updated: [
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    name: 'another-feature-flag',
 | 
					 | 
				
			||||||
                    type: 'release',
 | 
					 | 
				
			||||||
                    enabled: true,
 | 
					 | 
				
			||||||
                    project: 'default',
 | 
					 | 
				
			||||||
                    stale: false,
 | 
					 | 
				
			||||||
                    strategies: [
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            name: 'flexibleRollout',
 | 
					 | 
				
			||||||
                            constraints: [],
 | 
					 | 
				
			||||||
                            parameters: {
 | 
					 | 
				
			||||||
                                groupId: 'another-feature-flag',
 | 
					 | 
				
			||||||
                                rollout: '100',
 | 
					 | 
				
			||||||
                                stickiness: 'default',
 | 
					 | 
				
			||||||
                            },
 | 
					 | 
				
			||||||
                            variants: [],
 | 
					 | 
				
			||||||
                        },
 | 
					 | 
				
			||||||
                    ],
 | 
					 | 
				
			||||||
                    variants: [],
 | 
					 | 
				
			||||||
                    description: null,
 | 
					 | 
				
			||||||
                    impressionData: false,
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            ],
 | 
					 | 
				
			||||||
            removed: [],
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const revisions = deltaCache.getRevisions();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Check that the base has been changed and merged correctly
 | 
					 | 
				
			||||||
        expect(revisions.length).toBe(2);
 | 
					 | 
				
			||||||
        expect(revisions[0].updated.length).toBe(2);
 | 
					 | 
				
			||||||
        expect(revisions[0].updated).toEqual(
 | 
					 | 
				
			||||||
            expect.arrayContaining([
 | 
					 | 
				
			||||||
                expect.objectContaining({ name: 'test-flag' }),
 | 
					 | 
				
			||||||
                expect.objectContaining({ name: 'my-feature-flag' }),
 | 
					 | 
				
			||||||
            ]),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@ -1,48 +0,0 @@
 | 
				
			|||||||
import type { Revision } from './client-feature-toggle-delta';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
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 RevisionDelta {
 | 
					 | 
				
			||||||
    private delta: Revision[];
 | 
					 | 
				
			||||||
    private maxLength: number;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    constructor(data: Revision[] = [], maxLength: number = 20) {
 | 
					 | 
				
			||||||
        this.delta = data;
 | 
					 | 
				
			||||||
        this.maxLength = maxLength;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public addRevision(revision: Revision): void {
 | 
					 | 
				
			||||||
        if (this.delta.length >= this.maxLength) {
 | 
					 | 
				
			||||||
            this.changeBase();
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        this.delta = [...this.delta, revision];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public getRevisions(): Revision[] {
 | 
					 | 
				
			||||||
        return this.delta;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public hasRevision(revisionId: number): boolean {
 | 
					 | 
				
			||||||
        return this.delta.some(
 | 
					 | 
				
			||||||
            (revision) => revision.revisionId === revisionId,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private changeBase(): void {
 | 
					 | 
				
			||||||
        if (!(this.delta.length >= 2)) return;
 | 
					 | 
				
			||||||
        const base = this.delta[0];
 | 
					 | 
				
			||||||
        const newBase = this.delta[1];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        newBase.removed = mergeWithoutDuplicates(base.removed, newBase.removed);
 | 
					 | 
				
			||||||
        newBase.updated = mergeWithoutDuplicates(base.updated, newBase.updated);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        this.delta = [newBase, ...this.delta.slice(2)];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -3,22 +3,42 @@ import type { ClientFeaturesDeltaSchema } from './client-features-delta-schema';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
test('clientFeaturesDeltaSchema all fields', () => {
 | 
					test('clientFeaturesDeltaSchema all fields', () => {
 | 
				
			||||||
    const data: ClientFeaturesDeltaSchema = {
 | 
					    const data: ClientFeaturesDeltaSchema = {
 | 
				
			||||||
        revisionId: 6,
 | 
					        events: [
 | 
				
			||||||
        updated: [
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                impressionData: false,
 | 
					                eventId: 1,
 | 
				
			||||||
                enabled: false,
 | 
					                type: 'feature-removed',
 | 
				
			||||||
                name: 'base_feature',
 | 
					                featureName: 'removed-event',
 | 
				
			||||||
                description: null,
 | 
					            },
 | 
				
			||||||
                project: 'default',
 | 
					            {
 | 
				
			||||||
                stale: false,
 | 
					                eventId: 1,
 | 
				
			||||||
                type: 'release',
 | 
					                type: 'feature-updated',
 | 
				
			||||||
                variants: [],
 | 
					                feature: {
 | 
				
			||||||
                strategies: [],
 | 
					                    impressionData: false,
 | 
				
			||||||
 | 
					                    enabled: false,
 | 
				
			||||||
 | 
					                    name: 'base_feature',
 | 
				
			||||||
 | 
					                    description: null,
 | 
				
			||||||
 | 
					                    project: 'default',
 | 
				
			||||||
 | 
					                    stale: false,
 | 
				
			||||||
 | 
					                    type: 'release',
 | 
				
			||||||
 | 
					                    variants: [],
 | 
				
			||||||
 | 
					                    strategies: [],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                eventId: 1,
 | 
				
			||||||
 | 
					                type: 'segment-removed',
 | 
				
			||||||
 | 
					                segmentId: 33,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                eventId: 1,
 | 
				
			||||||
 | 
					                type: 'segment-updated',
 | 
				
			||||||
 | 
					                segment: {
 | 
				
			||||||
 | 
					                    id: 3,
 | 
				
			||||||
 | 
					                    name: 'hello',
 | 
				
			||||||
 | 
					                    constraints: [],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        removed: [],
 | 
					 | 
				
			||||||
        segments: [],
 | 
					 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(
 | 
					    expect(
 | 
				
			||||||
 | 
				
			|||||||
@ -13,33 +13,76 @@ import { dependentFeatureSchema } from './dependent-feature-schema';
 | 
				
			|||||||
export const clientFeaturesDeltaSchema = {
 | 
					export const clientFeaturesDeltaSchema = {
 | 
				
			||||||
    $id: '#/components/schemas/clientFeaturesDeltaSchema',
 | 
					    $id: '#/components/schemas/clientFeaturesDeltaSchema',
 | 
				
			||||||
    type: 'object',
 | 
					    type: 'object',
 | 
				
			||||||
    required: ['updated', 'revisionId', 'removed'],
 | 
					    required: ['events'],
 | 
				
			||||||
    description: 'Schema for delta updates of feature configurations.',
 | 
					    description: 'Schema for delta updates of feature configurations.',
 | 
				
			||||||
    properties: {
 | 
					    properties: {
 | 
				
			||||||
        updated: {
 | 
					        events: {
 | 
				
			||||||
            description: 'A list of updated feature configurations.',
 | 
					            description: 'A list of delta events.',
 | 
				
			||||||
            type: 'array',
 | 
					            type: 'array',
 | 
				
			||||||
            items: {
 | 
					            items: {
 | 
				
			||||||
                $ref: '#/components/schemas/clientFeatureSchema',
 | 
					                type: 'object',
 | 
				
			||||||
            },
 | 
					                anyOf: [
 | 
				
			||||||
        },
 | 
					                    {
 | 
				
			||||||
        revisionId: {
 | 
					                        type: 'object',
 | 
				
			||||||
            type: 'number',
 | 
					                        required: ['eventId', 'type', 'feature'],
 | 
				
			||||||
            description: 'The revision ID of the delta update.',
 | 
					                        properties: {
 | 
				
			||||||
        },
 | 
					                            eventId: { type: 'number' },
 | 
				
			||||||
        removed: {
 | 
					                            type: { type: 'string', enum: ['feature-updated'] },
 | 
				
			||||||
            description: 'A list of feature names that were removed.',
 | 
					                            feature: {
 | 
				
			||||||
            type: 'array',
 | 
					                                $ref: '#/components/schemas/clientFeatureSchema',
 | 
				
			||||||
            items: {
 | 
					                            },
 | 
				
			||||||
                type: 'string',
 | 
					                        },
 | 
				
			||||||
            },
 | 
					                    },
 | 
				
			||||||
        },
 | 
					                    {
 | 
				
			||||||
        segments: {
 | 
					                        type: 'object',
 | 
				
			||||||
            description:
 | 
					                        required: ['eventId', 'type', 'featureName'],
 | 
				
			||||||
                'A list of [Segments](https://docs.getunleash.io/reference/segments) configured for this Unleash instance',
 | 
					                        properties: {
 | 
				
			||||||
            type: 'array',
 | 
					                            eventId: { type: 'number' },
 | 
				
			||||||
            items: {
 | 
					                            type: { type: 'string', enum: ['feature-removed'] },
 | 
				
			||||||
                $ref: '#/components/schemas/clientSegmentSchema',
 | 
					                            featureName: { type: 'string' },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        type: 'object',
 | 
				
			||||||
 | 
					                        required: ['eventId', 'type', 'segment'],
 | 
				
			||||||
 | 
					                        properties: {
 | 
				
			||||||
 | 
					                            eventId: { type: 'number' },
 | 
				
			||||||
 | 
					                            type: { type: 'string', enum: ['segment-updated'] },
 | 
				
			||||||
 | 
					                            segment: {
 | 
				
			||||||
 | 
					                                $ref: '#/components/schemas/clientSegmentSchema',
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        type: 'object',
 | 
				
			||||||
 | 
					                        required: ['eventId', 'type', 'segmentId'],
 | 
				
			||||||
 | 
					                        properties: {
 | 
				
			||||||
 | 
					                            eventId: { type: 'number' },
 | 
				
			||||||
 | 
					                            type: { type: 'string', enum: ['segment-removed'] },
 | 
				
			||||||
 | 
					                            segmentId: { type: 'number' },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        type: 'object',
 | 
				
			||||||
 | 
					                        required: ['type', 'features', 'segments', 'eventId'],
 | 
				
			||||||
 | 
					                        properties: {
 | 
				
			||||||
 | 
					                            eventId: { type: 'number' },
 | 
				
			||||||
 | 
					                            type: { type: 'string', enum: ['hydration'] },
 | 
				
			||||||
 | 
					                            features: {
 | 
				
			||||||
 | 
					                                type: 'array',
 | 
				
			||||||
 | 
					                                items: {
 | 
				
			||||||
 | 
					                                    $ref: '#/components/schemas/clientFeatureSchema',
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                            segments: {
 | 
				
			||||||
 | 
					                                type: 'array',
 | 
				
			||||||
 | 
					                                items: {
 | 
				
			||||||
 | 
					                                    $ref: '#/components/schemas/clientSegmentSchema',
 | 
				
			||||||
 | 
					                                },
 | 
				
			||||||
 | 
					                            },
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user