Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
57300db
Handle undefined key in evaluator to support evaluation without key/t…
EmilianoSanchez Mar 13, 2026
64feb01
Consolidate impression logging into single message when queueing
EmilianoSanchez Mar 13, 2026
df9f00f
Refactor /splitChanges DTO to make the SDK more robust in case of nul…
EmilianoSanchez Mar 13, 2026
2897640
Merge branch 'refactor-fallback-calculator' into refactor-evaluator-t…
EmilianoSanchez Mar 18, 2026
bd199f0
Merge branch 'sdk-configs-baseline' into refactor-evaluator-to-suppor…
EmilianoSanchez Mar 18, 2026
aabd919
Add IConfig interface and fetchConfigs method
EmilianoSanchez Mar 18, 2026
71912ea
Merge branch 'sdk-configs-baseline' into refactor-evaluator-to-suppor…
EmilianoSanchez Mar 19, 2026
56fad13
Merge branch 'refactor-evaluator-to-support-no-target' into sdk-confi…
EmilianoSanchez Mar 19, 2026
4614e36
Fix
EmilianoSanchez Mar 19, 2026
3aed036
Add default condition handling in configsFetcher and update defaultTr…
EmilianoSanchez Mar 23, 2026
c64b57a
Simplify configsFetcher
EmilianoSanchez Mar 23, 2026
12ee171
Merge branch 'sdk-configs-baseline' into refactor-evaluator-to-suppor…
EmilianoSanchez Mar 19, 2026
d4e5cd5
Add evaluateDefaultTreatment function to handle default treatment eva…
EmilianoSanchez Mar 25, 2026
fc89992
Refactor evaluation handling
EmilianoSanchez Mar 25, 2026
8ec6a80
Merge branch 'refactor-evaluator-to-support-no-target' into sdk-confi…
EmilianoSanchez Mar 25, 2026
a1f9344
Add TS definitions for Configs SDK
EmilianoSanchez Apr 8, 2026
5e68127
Update Configs DTO
EmilianoSanchez Apr 8, 2026
280d487
Merge branch 'sdk-configs-handle-configs-dto' into sdk-configs-fallba…
EmilianoSanchez Apr 8, 2026
7f1ca74
Add fallback configuration calculator and sanitizer with correspondin…
EmilianoSanchez Apr 8, 2026
0db72ee
Test
EmilianoSanchez Apr 8, 2026
f41c1de
Refactor clientInputValidation tests to remove unnecessary type annot…
EmilianoSanchez Apr 8, 2026
68f0f17
Merge pull request #487 from splitio/sdk-configs-fallback-configs
EmilianoSanchez Apr 8, 2026
6aa1d57
Merge pull request #482 from splitio/sdk-configs-handle-configs-dto
EmilianoSanchez Apr 8, 2026
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
3 changes: 2 additions & 1 deletion src/dtos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export interface IRBSegment {
} | null
}

// @TODO: rename to IDefinition (Configs and Feature Flags are definitions)
export interface ISplit {
name: string,
changeNumber: number,
Expand All @@ -231,7 +232,7 @@ export interface ISplit {
trafficAllocation?: number,
trafficAllocationSeed?: number
configurations?: {
[treatmentName: string]: string
[treatmentName: string]: string | SplitIO.JsonObject
},
sets?: string[],
impressionsDisabled?: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FallbackConfigsCalculator } from '../';
import SplitIO from '../../../../types/splitio';
import { CONTROL } from '../../../utils/constants';

describe('FallbackConfigsCalculator', () => {
test('returns specific fallback if config name exists', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {
'configA': { variant: 'VARIANT_A', value: { key: 1 } },
},
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('configA', 'label by name');

expect(result).toEqual({
treatment: 'VARIANT_A',
config: { key: 1 },
label: 'fallback - label by name',
});
});

test('returns global fallback if config name is missing and global exists', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {},
global: { variant: 'GLOBAL_VARIANT', value: { global: true } },
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('missingConfig', 'label by global');

expect(result).toEqual({
treatment: 'GLOBAL_VARIANT',
config: { global: true },
label: 'fallback - label by global',
});
});

test('returns control fallback if config name and global are missing', () => {
const fallbacks: SplitIO.FallbackConfigs = {
byName: {},
};
const calculator = FallbackConfigsCalculator(fallbacks);
const result = calculator('missingConfig', 'label by noFallback');

expect(result).toEqual({
treatment: CONTROL,
config: null,
label: 'label by noFallback',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { isValidConfigName, isValidConfig, sanitizeFallbacks } from '../fallbackSanitizer';
import SplitIO from '../../../../types/splitio';
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';

describe('FallbackConfigsSanitizer', () => {
const validConfig: SplitIO.Config = { variant: 'on', value: { color: 'blue' } };
const invalidVariantConfig: SplitIO.Config = { variant: ' ', value: { color: 'blue' } };
const invalidValueConfig = { variant: 'on', value: 'not_an_object' } as unknown as SplitIO.Config;
const fallbackMock = {
global: undefined,
byName: {}
};

beforeEach(() => {
loggerMock.mockClear();
});

describe('isValidConfigName', () => {
test('returns true for a valid config name', () => {
expect(isValidConfigName('my_config')).toBe(true);
});

test('returns false for a name longer than 100 chars', () => {
const longName = 'a'.repeat(101);
expect(isValidConfigName(longName)).toBe(false);
});

test('returns false if the name contains spaces', () => {
expect(isValidConfigName('invalid config')).toBe(false);
});

test('returns false if the name is not a string', () => {
// @ts-ignore
expect(isValidConfigName(true)).toBe(false);
});
});

describe('isValidConfig', () => {
test('returns true for a valid config', () => {
expect(isValidConfig(validConfig)).toBe(true);
});

test('returns false for null or undefined', () => {
expect(isValidConfig()).toBe(false);
expect(isValidConfig(undefined)).toBe(false);
});

test('returns false for a variant longer than 100 chars', () => {
const long: SplitIO.Config = { variant: 'a'.repeat(101), value: {} };
expect(isValidConfig(long)).toBe(false);
});

test('returns false if variant does not match regex pattern', () => {
const invalid: SplitIO.Config = { variant: 'invalid variant!', value: {} };
expect(isValidConfig(invalid)).toBe(false);
});

test('returns false if value is not an object', () => {
expect(isValidConfig(invalidValueConfig)).toBe(false);
});
});

describe('sanitizeGlobal', () => {
test('returns the config if valid', () => {
expect(sanitizeFallbacks(loggerMock, { ...fallbackMock, global: validConfig })).toEqual({ ...fallbackMock, global: validConfig });
expect(loggerMock.error).not.toHaveBeenCalled();
});

test('returns undefined and logs error if variant is invalid', () => {
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidVariantConfig });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalledWith(
expect.stringContaining('Fallback configs - Discarded fallback')
);
});

test('returns undefined and logs error if value is invalid', () => {
const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, global: invalidValueConfig });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalledWith(
expect.stringContaining('Fallback configs - Discarded fallback')
);
});
});

describe('sanitizeByName', () => {
test('returns a sanitized map with valid entries only', () => {
const input = {
valid_config: validConfig,
'invalid config': validConfig,
bad_variant: invalidVariantConfig,
};

const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });

expect(result).toEqual({ ...fallbackMock, byName: { valid_config: validConfig } });
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid config name + bad_variant
});

test('returns empty object if all invalid', () => {
const input = {
'invalid config': invalidVariantConfig,
};

const result = sanitizeFallbacks(loggerMock, { ...fallbackMock, byName: input });
expect(result).toEqual(fallbackMock);
expect(loggerMock.error).toHaveBeenCalled();
});

test('returns same object if all valid', () => {
const input = {
...fallbackMock,
byName: {
config_one: validConfig,
config_two: { variant: 'valid_2', value: { key: 'val' } },
}
};

const result = sanitizeFallbacks(loggerMock, input);
expect(result).toEqual(input);
expect(loggerMock.error).not.toHaveBeenCalled();
});
});

describe('sanitizeFallbacks', () => {
test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, 'invalid_fallbacks');
expect(result).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith(
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
);
});

test('returns undefined and logs error if fallbacks is not an object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, true);
expect(result).toBeUndefined();
expect(loggerMock.error).toHaveBeenCalledWith(
'Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties'
);
});

test('sanitizes both global and byName fallbacks for empty object', () => { // @ts-expect-error
const result = sanitizeFallbacks(loggerMock, { global: {} });
expect(result).toEqual({ global: undefined, byName: {} });
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import SplitIO from '../../../../types/splitio';
import { ILogger } from '../../../logger/types';
import { isObject, isString } from '../../../utils/lang';

enum FallbackDiscardReason {
ConfigName = 'Invalid config name (max 100 chars, no spaces)',
Variant = 'Invalid variant (max 100 chars and must match pattern)',
Value = 'Invalid value (must be an object)',
}

const VARIANT_PATTERN = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;

export function isValidConfigName(name: string): boolean {
return name.length <= 100 && !name.includes(' ');
}

export function isValidConfig(config?: SplitIO.Config): boolean {
if (!isObject(config)) return false;
if (!isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) return false;
if (!isObject(config!.value)) return false;
return true;
}

function sanitizeGlobal(logger: ILogger, config?: SplitIO.Config): SplitIO.Config | undefined {
if (config === undefined) return undefined;
if (!isValidConfig(config)) {
if (!isObject(config) || !isString(config!.variant) || config!.variant.length > 100 || !VARIANT_PATTERN.test(config!.variant)) {
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Variant}`);
} else {
logger.error(`Fallback configs - Discarded fallback: ${FallbackDiscardReason.Value}`);
}
return undefined;
}
return config;
}

function sanitizeByName(
logger: ILogger,
byNameFallbacks?: Record<string, SplitIO.Config>
): Record<string, SplitIO.Config> {
const sanitizedByName: Record<string, SplitIO.Config> = {};

if (!isObject(byNameFallbacks)) return sanitizedByName;

Object.keys(byNameFallbacks!).forEach((configName) => {
const config = byNameFallbacks![configName];

if (!isValidConfigName(configName)) {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.ConfigName}`);
return;
}

if (!isValidConfig(config)) {
if (!isObject(config) || !isString(config.variant) || config.variant.length > 100 || !VARIANT_PATTERN.test(config.variant)) {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Variant}`);
} else {
logger.error(`Fallback configs - Discarded config '${configName}': ${FallbackDiscardReason.Value}`);
}
return;
}

sanitizedByName[configName] = config;
});

return sanitizedByName;
}

export function sanitizeFallbacks(logger: ILogger, fallbacks: SplitIO.FallbackConfigs): SplitIO.FallbackConfigs | undefined {
if (!isObject(fallbacks)) {
logger.error('Fallback configs - Discarded configuration: it must be an object with optional `global` and `byName` properties');
return;
}

return {
global: sanitizeGlobal(logger, fallbacks.global),
byName: sanitizeByName(logger, fallbacks.byName)
};
}
24 changes: 24 additions & 0 deletions src/evaluator/fallbackConfigsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IFallbackCalculator } from '../fallbackTreatmentsCalculator/index';
import { CONTROL } from '../../utils/constants';
import SplitIO from '../../../types/splitio';

export const FALLBACK_PREFIX = 'fallback - ';

export function FallbackConfigsCalculator(fallbacks: SplitIO.FallbackConfigs = {}): IFallbackCalculator {

return (configName: string, label = '') => {
const fallback = fallbacks.byName?.[configName] || fallbacks.global;

return fallback ?
{
treatment: fallback.variant,
config: fallback.value,
label: `${FALLBACK_PREFIX}${label}`,
} :
{
treatment: CONTROL,
config: null,
label,
};
};
}
10 changes: 7 additions & 3 deletions src/evaluator/fallbackTreatmentsCalculator/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { FallbackTreatmentConfiguration, TreatmentWithConfig } from '../../../types/splitio';
import { CONTROL } from '../../utils/constants';
import { isString } from '../../utils/lang';
import SplitIO from '../../../types/splitio';

export type IFallbackTreatmentsCalculator = (flagName: string, label?: string) => TreatmentWithConfig & { label: string };
export type IFallbackCalculator = (definitionName: string, label?: string) => {
treatment: string;
config: string | null | SplitIO.JsonObject;
label: string
};

export const FALLBACK_PREFIX = 'fallback - ';

export function FallbackTreatmentsCalculator(fallbacks: FallbackTreatmentConfiguration = {}): IFallbackTreatmentsCalculator {
export function FallbackTreatmentsCalculator(fallbacks: SplitIO.FallbackTreatmentConfiguration = {}): IFallbackCalculator {

return (flagName: string, label = '') => {
const fallback = fallbacks.byFlag?.[flagName] || fallbacks.global;
Expand Down
Loading