Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f45b39c
chore: rename configuration.ts to configuration-wire-types.ts
typotter Feb 19, 2025
704d2e5
chore: deprecate fetchFlagConfigurations in favour of startPolling
typotter Feb 19, 2025
0e9c2f5
feat: Wrap configuration store access in new Configuration object
typotter Feb 19, 2025
8b11bb4
feat: ConfigurationRequestor.getConfiguration
typotter Feb 19, 2025
84857ad
merge main
typotter Feb 19, 2025
688b200
chore: delegate isObfuscated check to i-config
typotter Feb 19, 2025
40a953d
chore: delegate getFlagKeys to i-config
typotter Feb 19, 2025
3786b78
chore: delegate getFlagConfigurations to i-config
typotter Feb 19, 2025
2ab3dda
chore: delegate getFlag->getNormalizedFlag and evalDetails method to …
typotter Feb 19, 2025
06fc3e5
fix: point default config at other config stores
typotter Feb 19, 2025
1cc8cea
chore: remove findBanditByVariation and delegate to i-config
typotter Feb 19, 2025
f7e539e
chore: remove getBandit and delegate to i-config
typotter Feb 19, 2025
39aed59
feat: i-config.isInitialized
typotter Feb 19, 2025
421470d
remove getConfigDetails and delegate to i-config
typotter Feb 19, 2025
5b34fc7
feat: getters for other config stores and some renaming
typotter Feb 19, 2025
15e3262
chore: delegate banditVariations.get to i-config
typotter Feb 19, 2025
bf412f8
move flag config expired check to config requestor
typotter Feb 19, 2025
1ff4f35
v4.11.0
typotter Feb 19, 2025
5fc7ef3
chore: drop unimplemented and add specific string types
typotter Feb 19, 2025
3ab4301
chore: rename new config file to make a nicer diff
typotter Feb 19, 2025
3533fa5
chore: save startPolling for later
typotter Feb 19, 2025
b4b44b9
chore:lint
typotter Feb 19, 2025
4a1231b
chore: take TODO
typotter Feb 19, 2025
0f1874e
chore: don't duplicate code
typotter Feb 19, 2025
3f86324
Merge branch 'main' into tp/i-configuration
typotter Feb 21, 2025
94e7f3e
comments
typotter Feb 21, 2025
8796c23
RO config WIP
typotter Mar 5, 2025
19ba44d
merge main
typotter Mar 5, 2025
b08e9f5
return a copy of the config
typotter Mar 5, 2025
c284c36
update tests
typotter Mar 5, 2025
bb75ce3
copy config details and rename class
typotter Mar 5, 2025
25d9c25
lint
typotter Mar 5, 2025
0f883d8
updates
typotter Mar 5, 2025
f6bd4de
Merge branch 'main' into tp/i-configuration
typotter Mar 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/configuration-requestor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
IHttpClient,
IUniversalFlagConfigResponse,
} from './http-client';
import { StoreBackedConfiguration } from './i-configuration';
import { ImmutableConfiguration } from './i-configuration';
import { BanditParameters, BanditVariation, Flag } from './interfaces';

describe('ConfigurationRequestor', () => {
Expand Down Expand Up @@ -506,7 +506,7 @@
);

const config = requestor.getConfiguration();
expect(config).toBeInstanceOf(StoreBackedConfiguration);
expect(config).toBeInstanceOf(ImmutableConfiguration);
expect(config.getFlagKeys()).toEqual([]);
});

Expand All @@ -521,7 +521,7 @@
await requestor.fetchAndStoreConfigurations();

const config = requestor.getConfiguration();
expect(config).toBeInstanceOf(StoreBackedConfiguration);
expect(config).toBeInstanceOf(ImmutableConfiguration);
expect(config.getFlagKeys()).toEqual(['test_flag']);
});
});
Expand All @@ -534,10 +534,11 @@
banditVariationStore,
banditModelStore,
);
const config = requestor.getConfiguration();

await requestor.fetchAndStoreConfigurations();

const config = requestor.getConfiguration();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since config is a snapshot, we take it after the fetch&store


expect(config.getFlagKeys()).toEqual(['test_flag']);
expect(config.getFlagConfigDetails()).toEqual({
configEnvironment: { name: 'Test' },
Expand All @@ -554,10 +555,11 @@
banditVariationStore,
banditModelStore,
);
const config = requestor.getConfiguration();

await requestor.fetchAndStoreConfigurations();

const config = requestor.getConfiguration();

// Verify flag configuration
expect(config.getFlagKeys()).toEqual(['test_flag']);

Expand Down Expand Up @@ -610,7 +612,7 @@
banditModelStore,
);

const ufcResponse = {

Check warning on line 615 in src/configuration-requestor.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (18)

'ufcResponse' is assigned a value but never used

Check warning on line 615 in src/configuration-requestor.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (20)

'ufcResponse' is assigned a value but never used

Check warning on line 615 in src/configuration-requestor.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (22)

'ufcResponse' is assigned a value but never used

Check warning on line 615 in src/configuration-requestor.spec.ts

View workflow job for this annotation

GitHub Actions / lint-test-sdk (23)

'ufcResponse' is assigned a value but never used
flags: { test_flag: { key: 'test_flag', value: true } },
banditReferences: {
bandit: {
Expand Down
5 changes: 4 additions & 1 deletion src/configuration-requestor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { BanditVariation, BanditParameters, Flag, BanditReference } from './inte
export default class ConfigurationRequestor {
private banditModelVersions: string[] = [];
private readonly configuration: StoreBackedConfiguration;
private configurationCopy: IConfiguration;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

save and return the copy only


constructor(
private readonly httpClient: IHttpClient,
Expand All @@ -25,14 +26,15 @@ export default class ConfigurationRequestor {
this.banditVariationConfigurationStore,
this.banditModelConfigurationStore,
);
this.configurationCopy = this.configuration.copy();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be large; I wonder if we do the copying just in time when getConfiguration() is called. We could still check this.configurationCopy so that we only do it once.

}

public isFlagConfigExpired(): Promise<boolean> {
return this.flagConfigurationStore.isExpired();
}

public getConfiguration(): IConfiguration {
return this.configuration;
return this.configurationCopy;
}

async fetchAndStoreConfigurations(): Promise<void> {
Expand Down Expand Up @@ -92,6 +94,7 @@ export default class ConfigurationRequestor {
banditModelPacket,
)
) {
this.configurationCopy = this.configuration.copy();
// TODO: Notify that config updated.
}
}
Expand Down
229 changes: 227 additions & 2 deletions src/i-configuration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { IConfigurationStore } from './configuration-store/configuration-store';
import { StoreBackedConfiguration } from './i-configuration';
import { BanditParameters, BanditVariation, Environment, Flag, ObfuscatedFlag } from './interfaces';
import { ImmutableConfiguration, StoreBackedConfiguration } from './i-configuration';
import {
BanditParameters,
BanditVariation,
ConfigDetails,
Environment,
Flag,
ObfuscatedFlag,
VariationType,
} from './interfaces';
import { BanditKey, FlagKey } from './types';

describe('StoreBackedConfiguration', () => {
Expand Down Expand Up @@ -428,4 +436,221 @@ describe('StoreBackedConfiguration', () => {
]);
});
});

describe('copy', () => {
it('should prevent modification of stored config', () => {
const config = new StoreBackedConfiguration(mockFlagStore);
const mockFlag: Flag = { key: 'test-flag', enabled: true } as Flag;
mockFlagStore.get.mockReturnValue(mockFlag);
mockFlagStore.entries.mockReturnValue({ [mockFlag.key]: mockFlag });

const storedFlag = config.getFlag('test-flag');
expect(storedFlag?.enabled).toBeTruthy();

const roConfig = config.copy();
expect(roConfig).toBeInstanceOf(ImmutableConfiguration);

const flag = roConfig.getFlag('test-flag');
expect(flag).toBeTruthy();
if (flag) {
flag.enabled = false;
} else {
fail('flag is null');
}

expect(flag?.enabled).toBeFalsy();
expect(storedFlag?.enabled).toBeTruthy();
});
});
});

describe('ImmutableConfiguration', () => {
let config: ImmutableConfiguration;

const mockFlags: Record<string, Flag> = {
'feature-a': {
key: 'feature-a',
variationType: VariationType.BOOLEAN,
enabled: true,
variations: {},
allocations: [],
totalShards: 10_000,
},
'feature-b': {
key: 'feature-b',
variationType: VariationType.STRING,
enabled: true,
variations: {},
allocations: [],
totalShards: 10_000,
},
};

const mockBanditVariations: Record<string, BanditVariation[]> = {
'feature-a': [
{
key: 'bandit-1',
variationValue: 'bandit-1',
flagKey: 'feature-a',
variationKey: 'bandit-1',
},
],
};

const mockBandits: Record<string, BanditParameters> = {
'bandit-1': {
banditKey: 'bandit-1',
modelName: 'falcon',
modelVersion: 'v123',
modelData: {
gamma: 0,
defaultActionScore: 0,
actionProbabilityFloor: 0,
coefficients: {},
},
},
};

const mockConfigDetails: ConfigDetails = {
configEnvironment: { name: 'test' },
configFormat: 'SERVER',
configPublishedAt: '2024-03-20T00:00:00Z',
configFetchedAt: '2024-03-20T00:00:00Z',
};

beforeEach(() => {
config = new ImmutableConfiguration(
mockFlags,
true, // initialized
false, // obfuscated
mockConfigDetails,
mockBanditVariations,
mockBandits,
);
});

describe('immutability', () => {
it('should create deep copies of input data', () => {
const originalFlags = { ...mockFlags };
const flags = config.getFlags();

// Attempt to modify the returned data
flags['feature-a'].enabled = false;

// Verify original configuration remains unchanged
expect(originalFlags['feature-a'].enabled).toBeTruthy();
Comment on lines +537 to +541
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

});

it('should create deep copies of bandit variations', () => {
const variations = config.getBanditVariations();
const originalVariation = [...mockBanditVariations['feature-a']];

variations['feature-a'][0].variationValue = 'modified';

expect(originalVariation[0].variationValue).toEqual('bandit-1');
});

it('should create deep copies of bandits', () => {
const bandits = config.getBandits();
const originalBandit = { ...mockBandits['bandit-1'] };

bandits['bandit-1'].modelName = 'eagle';

expect(originalBandit.modelName).toEqual('falcon');
});

it('should create deep copies of config details', () => {
const details = config.getFlagConfigDetails();
const originalDetails = { ...mockConfigDetails };

details.configFormat = 'CLIENT';

expect(originalDetails.configFormat).toEqual('SERVER');
});
});

describe('flag operations', () => {
it('should get existing flag', () => {
expect(config.getFlag('feature-a')).toEqual(mockFlags['feature-a']);
});

it('should return null for non-existent flag', () => {
expect(config.getFlag('non-existent')).toBeNull();
});

it('should get all flags', () => {
expect(config.getFlags()).toEqual(mockFlags);
});

it('should get all flag keys', () => {
expect(config.getFlagKeys()).toEqual(['feature-a', 'feature-b']);
});
});

describe('bandit operations', () => {
it('should get bandit variations for flag', () => {
expect(config.getFlagBanditVariations('feature-a')).toEqual(
mockBanditVariations['feature-a'],
);
});

it('should return empty array for flag without bandit variations', () => {
expect(config.getFlagBanditVariations('feature-b')).toEqual([]);
});

it('should get specific bandit', () => {
expect(config.getBandit('bandit-1')).toEqual(mockBandits['bandit-1']);
});

it('should return null for non-existent bandit', () => {
expect(config.getBandit('non-existent')).toBeNull();
});

it('should get flag variation bandit', () => {
expect(config.getFlagVariationBandit('feature-a', 'bandit-1')).toEqual(
mockBandits['bandit-1'],
);
});

it('should return null for non-matching variation value', () => {
expect(config.getFlagVariationBandit('feature-a', 'control')).toBeNull();
});
});

describe('configuration state', () => {
it('should return initialization state', () => {
expect(config.isInitialized()).toBe(true);
});

it('should return obfuscation state', () => {
expect(config.isObfuscated()).toBe(false);
});
});

describe('edge cases', () => {
it('should handle configuration without bandit variations', () => {
const configWithoutBandits = new ImmutableConfiguration(
mockFlags,
true,
false,
mockConfigDetails,
);

expect(configWithoutBandits.getBanditVariations()).toEqual({});
expect(configWithoutBandits.getFlagBanditVariations('feature-a')).toEqual([]);
});

it('should handle configuration without bandits', () => {
const configWithoutBandits = new ImmutableConfiguration(
mockFlags,
true,
false,
mockConfigDetails,
mockBanditVariations,
);

expect(configWithoutBandits.getBandits()).toEqual({});
expect(configWithoutBandits.getBandit('bandit-1')).toBeNull();
});
});
});
Loading
Loading