mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +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';
|
||||
import isEqual from 'lodash.isequal';
|
||||
import { diff } from 'json-diff';
|
||||
import type { DeltaHydrationEvent } from './delta/client-feature-toggle-delta-types';
|
||||
|
||||
const version = 2;
|
||||
|
||||
@ -191,22 +192,34 @@ export default class FeatureController extends Controller {
|
||||
const sortedToggles = features.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
const sortedNewToggles = delta?.updated.sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
if (delta?.events[0].type === 'hydration') {
|
||||
const hydrationEvent: DeltaHydrationEvent =
|
||||
delta?.events[0];
|
||||
const sortedNewToggles = hydrationEvent.features.sort(
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
if (
|
||||
!this.deepEqualIgnoreOrder(sortedToggles, sortedNewToggles)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`old features and new features are different. Old count ${
|
||||
features.length
|
||||
}, new count ${delta?.updated.length}, query ${JSON.stringify(query)},
|
||||
if (
|
||||
!this.deepEqualIgnoreOrder(
|
||||
sortedToggles,
|
||||
sortedNewToggles,
|
||||
)
|
||||
) {
|
||||
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(sortedToggles, sortedNewToggles),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Delta diff should have only hydration event, query ${JSON.stringify(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.storeFootprint();
|
||||
} catch (e) {
|
||||
this.logger.error('Delta diff failed', e);
|
||||
|
@ -37,7 +37,11 @@ const setupFeatures = async (
|
||||
{
|
||||
name: 'default',
|
||||
constraints: [
|
||||
{ contextName: 'userId', operator: 'IN', values: ['123'] },
|
||||
{
|
||||
contextName: 'userId',
|
||||
operator: 'IN',
|
||||
values: ['123'],
|
||||
},
|
||||
],
|
||||
parameters: {},
|
||||
},
|
||||
@ -88,17 +92,19 @@ test('should match with /api/client/delta', async () => {
|
||||
.expect('Content-Type', /json/)
|
||||
.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 () => {
|
||||
await setupFeatures(db, app);
|
||||
|
||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
||||
const currentRevisionId = body.revisionId;
|
||||
const { body, headers } = await app.request
|
||||
.get('/api/client/delta')
|
||||
.expect(200);
|
||||
const etag = headers.etag;
|
||||
|
||||
await app.request
|
||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
||||
.set('If-None-Match', etag)
|
||||
.get('/api/client/delta')
|
||||
.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 () => {
|
||||
await app.createFeature('base_feature');
|
||||
await syncRevisions();
|
||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
||||
const currentRevisionId = body.revisionId;
|
||||
const { body, headers } = await app.request
|
||||
.set('If-None-Match', null)
|
||||
.get('/api/client/delta')
|
||||
.expect(200);
|
||||
const etag = headers.etag;
|
||||
|
||||
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 syncRevisions();
|
||||
//@ts-ignore
|
||||
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
||||
|
||||
const { body: deltaBody } = await app.request
|
||||
.get('/api/client/delta')
|
||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
||||
.set('If-None-Match', etag)
|
||||
.expect(200);
|
||||
|
||||
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 () => {
|
||||
await app.services.configurationRevisionService.updateMaxRevisionId();
|
||||
// @ts-ignore
|
||||
await app.services.clientFeatureToggleService.clientFeatureToggleDelta.onUpdateRevisionEvent();
|
||||
};
|
||||
|
||||
test('archived features should not be returned as updated', async () => {
|
||||
await app.createFeature('base_feature');
|
||||
await syncRevisions();
|
||||
const { body } = await app.request.get('/api/client/delta').expect(200);
|
||||
const currentRevisionId = body.revisionId;
|
||||
const { body, headers } = await app.request
|
||||
.get('/api/client/delta')
|
||||
.expect(200);
|
||||
const etag = headers.etag;
|
||||
|
||||
expect(body).toMatchObject({
|
||||
updated: [
|
||||
events: [
|
||||
{
|
||||
name: 'base_feature',
|
||||
features: [
|
||||
{
|
||||
name: 'base_feature',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
const { body: deltaBody } = await app.request
|
||||
.get('/api/client/delta')
|
||||
.set('If-None-Match', `"${currentRevisionId}"`)
|
||||
.set('If-None-Match', etag)
|
||||
.expect(200);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedFeatures.revisionId === currentSdkRevisionId) {
|
||||
const lastEventId =
|
||||
changedFeatures.events[changedFeatures.events.length - 1].eventId;
|
||||
if (lastEventId === currentSdkRevisionId) {
|
||||
res.status(304);
|
||||
res.getHeaderNames().forEach((header) => res.removeHeader(header));
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('ETag', `"${changedFeatures.revisionId}"`);
|
||||
res.setHeader('ETag', `"${lastEventId}"`);
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
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 base = {
|
||||
@ -16,166 +21,86 @@ const mockAdd = (params): any => {
|
||||
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 ', () => {
|
||||
const revisionList = [
|
||||
const revisionList: DeltaEvent[] = [
|
||||
{
|
||||
revisionId: 1,
|
||||
updated: [
|
||||
mockAdd({ name: 'feature1' }),
|
||||
mockAdd({ name: 'feature2' }),
|
||||
mockAdd({ name: 'feature3' }),
|
||||
],
|
||||
removed: [],
|
||||
eventId: 2,
|
||||
type: 'feature-updated',
|
||||
feature: mockAdd({ name: 'feature4' }),
|
||||
},
|
||||
{
|
||||
revisionId: 2,
|
||||
updated: [mockAdd({ name: 'feature4' })],
|
||||
removed: [],
|
||||
},
|
||||
{
|
||||
revisionId: 3,
|
||||
updated: [mockAdd({ name: 'feature5' })],
|
||||
removed: [],
|
||||
eventId: 3,
|
||||
type: 'feature-updated',
|
||||
feature: mockAdd({ name: 'feature5' }),
|
||||
},
|
||||
];
|
||||
|
||||
const revisions = calculateRequiredClientRevision(revisionList, 1, [
|
||||
'default',
|
||||
]);
|
||||
const revisions = filterEventsByQuery(revisionList, 1, ['default'], '');
|
||||
|
||||
expect(revisions).toEqual({
|
||||
revisionId: 3,
|
||||
updated: [mockAdd({ name: 'feature4' }), mockAdd({ name: 'feature5' })],
|
||||
removed: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('project filter removes features not in project', () => {
|
||||
const revisionList = [
|
||||
expect(revisions).toEqual([
|
||||
{
|
||||
revisionId: 1,
|
||||
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
|
||||
removed: [],
|
||||
eventId: 2,
|
||||
type: 'feature-updated',
|
||||
feature: mockAdd({ name: 'feature4' }),
|
||||
},
|
||||
{
|
||||
revisionId: 2,
|
||||
updated: [mockAdd({ name: 'feature2', project: 'project2' })],
|
||||
removed: [],
|
||||
eventId: 3,
|
||||
type: 'feature-updated',
|
||||
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, [
|
||||
'project1',
|
||||
const revisions = filterEventsByQuery(revisionList, 0, ['project1'], 'ff');
|
||||
|
||||
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({
|
||||
revisionId: 2,
|
||||
updated: [mockAdd({ name: 'feature1', project: 'project1' })],
|
||||
removed: [],
|
||||
eventId: 1,
|
||||
type: 'hydration',
|
||||
features: [mockAdd({ name: 'myfeature2', project: 'project2' })],
|
||||
});
|
||||
});
|
||||
|
@ -9,7 +9,7 @@ import type {
|
||||
} from '../../../types';
|
||||
import type ConfigurationRevisionService 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 {
|
||||
FeatureConfigurationDeltaClient,
|
||||
IClientFeatureToggleDeltaReadModel,
|
||||
@ -18,92 +18,75 @@ import { CLIENT_DELTA_MEMORY } from '../../../metric-events';
|
||||
import type EventEmitter from 'events';
|
||||
import type { Logger } from '../../../logger';
|
||||
import type { ClientFeaturesDeltaSchema } from '../../../openapi';
|
||||
import {
|
||||
DELTA_EVENT_TYPES,
|
||||
type DeltaEvent,
|
||||
type DeltaHydrationEvent,
|
||||
isDeltaFeatureRemovedEvent,
|
||||
isDeltaFeatureUpdatedEvent,
|
||||
} from './client-feature-toggle-delta-types';
|
||||
|
||||
type DeletedFeature = {
|
||||
name: string;
|
||||
project: string;
|
||||
};
|
||||
type EnvironmentRevisions = Record<string, DeltaCache>;
|
||||
|
||||
export type RevisionDeltaEntry = {
|
||||
updated: FeatureConfigurationDeltaClient[];
|
||||
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[],
|
||||
export const filterEventsByQuery = (
|
||||
events: DeltaEvent[],
|
||||
requiredRevisionId: number,
|
||||
projects: string[],
|
||||
namePrefix: string,
|
||||
) => {
|
||||
const targetedRevisions = revisions.filter(
|
||||
(revision) => revision.revisionId > requiredRevisionId,
|
||||
);
|
||||
const projectFeatureRevisions = targetedRevisions.map((revision) =>
|
||||
filterRevisionByProject(revision, projects),
|
||||
const targetedEvents = events.filter(
|
||||
(revision) => revision.eventId > requiredRevisionId,
|
||||
);
|
||||
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 {
|
||||
private clientFeatureToggleDeltaReadModel: IClientFeatureToggleDeltaReadModel;
|
||||
|
||||
private delta: Revisions = {};
|
||||
private delta: EnvironmentRevisions = {};
|
||||
|
||||
private segments: IClientSegment[];
|
||||
|
||||
@ -159,7 +142,8 @@ export class ClientFeatureToggleDelta {
|
||||
): Promise<ClientFeaturesDeltaSchema | undefined> {
|
||||
const projects = query.project ? query.project : ['*'];
|
||||
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 hasDelta = this.delta[environment] !== undefined;
|
||||
@ -171,26 +155,48 @@ export class ClientFeatureToggleDelta {
|
||||
if (!hasSegments) {
|
||||
await this.updateSegments();
|
||||
}
|
||||
|
||||
if (requiredRevisionId >= this.currentRevisionId) {
|
||||
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(
|
||||
environmentRevisions,
|
||||
requiredRevisionId,
|
||||
projects,
|
||||
);
|
||||
return Promise.resolve(response);
|
||||
} else {
|
||||
const environmentEvents = this.delta[environment].getEvents();
|
||||
const events = filterEventsByQuery(
|
||||
environmentEvents,
|
||||
requiredRevisionId,
|
||||
projects,
|
||||
namePrefix,
|
||||
);
|
||||
|
||||
const revisionResponse: ClientFeaturesDeltaSchema = {
|
||||
...compressedRevision,
|
||||
segments: this.segments,
|
||||
removed: compressedRevision.removed.map((feature) => feature.name),
|
||||
};
|
||||
const response: ClientFeaturesDeltaSchema = {
|
||||
events: events.map((event) => {
|
||||
if (event.type === 'feature-removed') {
|
||||
const { project, ...rest } = event;
|
||||
return rest;
|
||||
}
|
||||
return event;
|
||||
}),
|
||||
};
|
||||
|
||||
return Promise.resolve(revisionResponse);
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
public async onUpdateRevisionEvent() {
|
||||
@ -220,7 +226,7 @@ export class ClientFeatureToggleDelta {
|
||||
latestRevision,
|
||||
);
|
||||
|
||||
const changedToggles = [
|
||||
const featuresUpdated = [
|
||||
...new Set(
|
||||
changeEvents
|
||||
.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.type === 'feature-archived')
|
||||
.map((event) => ({
|
||||
name: event.featureName!,
|
||||
eventId: latestRevision,
|
||||
type: DELTA_EVENT_TYPES.FEATURE_REMOVED,
|
||||
featureName: event.featureName!,
|
||||
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
|
||||
for (const environment of keys) {
|
||||
const newToggles = await this.getChangedToggles(
|
||||
environment,
|
||||
changedToggles,
|
||||
featuresUpdated,
|
||||
);
|
||||
this.delta[environment].addRevision({
|
||||
updated: newToggles,
|
||||
revisionId: latestRevision,
|
||||
removed,
|
||||
});
|
||||
const featuresUpdatedEvents: DeltaEvent[] = newToggles.map(
|
||||
(toggle) => ({
|
||||
eventId: latestRevision,
|
||||
type: DELTA_EVENT_TYPES.FEATURE_UPDATED,
|
||||
feature: toggle,
|
||||
}),
|
||||
);
|
||||
this.delta[environment].addEvents([
|
||||
...featuresUpdatedEvents,
|
||||
...featuresRemovedEvents,
|
||||
]);
|
||||
}
|
||||
this.currentRevisionId = latestRevision;
|
||||
}
|
||||
@ -257,11 +287,10 @@ export class ClientFeatureToggleDelta {
|
||||
environment: string,
|
||||
toggles: string[],
|
||||
): Promise<FeatureConfigurationDeltaClient[]> {
|
||||
const foundToggles = await this.getClientFeatures({
|
||||
return this.getClientFeatures({
|
||||
toggleNames: toggles,
|
||||
environment,
|
||||
});
|
||||
return foundToggles;
|
||||
}
|
||||
|
||||
public async initEnvironmentDelta(environment: string) {
|
||||
@ -273,14 +302,11 @@ export class ClientFeatureToggleDelta {
|
||||
this.currentRevisionId =
|
||||
await this.configurationRevisionService.getMaxRevisionId();
|
||||
|
||||
const delta = new RevisionDelta([
|
||||
{
|
||||
revisionId: this.currentRevisionId,
|
||||
updated: baseFeatures,
|
||||
removed: [],
|
||||
},
|
||||
]);
|
||||
this.delta[environment] = delta;
|
||||
this.delta[environment] = new DeltaCache({
|
||||
eventId: this.currentRevisionId,
|
||||
type: DELTA_EVENT_TYPES.HYDRATION,
|
||||
features: baseFeatures,
|
||||
});
|
||||
|
||||
this.storeFootprint();
|
||||
}
|
||||
@ -313,3 +339,5 @@ export class ClientFeatureToggleDelta {
|
||||
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', () => {
|
||||
const data: ClientFeaturesDeltaSchema = {
|
||||
revisionId: 6,
|
||||
updated: [
|
||||
events: [
|
||||
{
|
||||
impressionData: false,
|
||||
enabled: false,
|
||||
name: 'base_feature',
|
||||
description: null,
|
||||
project: 'default',
|
||||
stale: false,
|
||||
type: 'release',
|
||||
variants: [],
|
||||
strategies: [],
|
||||
eventId: 1,
|
||||
type: 'feature-removed',
|
||||
featureName: 'removed-event',
|
||||
},
|
||||
{
|
||||
eventId: 1,
|
||||
type: 'feature-updated',
|
||||
feature: {
|
||||
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(
|
||||
|
@ -13,33 +13,76 @@ import { dependentFeatureSchema } from './dependent-feature-schema';
|
||||
export const clientFeaturesDeltaSchema = {
|
||||
$id: '#/components/schemas/clientFeaturesDeltaSchema',
|
||||
type: 'object',
|
||||
required: ['updated', 'revisionId', 'removed'],
|
||||
required: ['events'],
|
||||
description: 'Schema for delta updates of feature configurations.',
|
||||
properties: {
|
||||
updated: {
|
||||
description: 'A list of updated feature configurations.',
|
||||
events: {
|
||||
description: 'A list of delta events.',
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/clientFeatureSchema',
|
||||
},
|
||||
},
|
||||
revisionId: {
|
||||
type: 'number',
|
||||
description: 'The revision ID of the delta update.',
|
||||
},
|
||||
removed: {
|
||||
description: 'A list of feature names that were removed.',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
segments: {
|
||||
description:
|
||||
'A list of [Segments](https://docs.getunleash.io/reference/segments) configured for this Unleash instance',
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/clientSegmentSchema',
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
required: ['eventId', 'type', 'feature'],
|
||||
properties: {
|
||||
eventId: { type: 'number' },
|
||||
type: { type: 'string', enum: ['feature-updated'] },
|
||||
feature: {
|
||||
$ref: '#/components/schemas/clientFeatureSchema',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['eventId', 'type', 'featureName'],
|
||||
properties: {
|
||||
eventId: { type: 'number' },
|
||||
type: { type: 'string', enum: ['feature-removed'] },
|
||||
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