Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ const allPropertiesJson = JSON.stringify({
},
});

const flagValuesOnlyJson = JSON.stringify({
flagValues: {
flag1: 'value1',
},
});

describe('FileDataInitializerFDv2', () => {
let mockFilesystem: MockFilesystem;
let logger: TestLogger;
Expand Down Expand Up @@ -374,4 +380,150 @@ describe('FileDataInitializerFDv2', () => {
expect(flag1Update.version).toBe(2);
expect(flag1Update.object.on).toBe(false); // Should be from file2
});

it('loads flags from the flagValues shorthand map', async () => {
mockFilesystem.fileData['flagValues.json'] = { timestamp: 0, data: flagValuesOnlyJson };

const options: FileSystemDataSourceConfiguration = {
type: 'file',
paths: ['flagValues.json'],
};

const initializer = new FileDataInitializerFDv2(options, platform, logger);
initializer.start(mockDataCallback, mockStatusCallback);

await jest.runAllTimersAsync();

expect(mockStatusCallback).toHaveBeenCalledWith(subsystem.DataSourceState.Valid);
expect(mockDataCallback).toHaveBeenCalled();

const dataCall = mockDataCallback.mock.calls[0];
const { payload } = dataCall[1];
const flagUpdates = payload.updates.filter((update: any) => update.kind === 'flag');
expect(flagUpdates.length).toBe(1);

const flag1Update = flagUpdates.find((update: any) => update.key === 'flag1');
expect(flag1Update).toBeDefined();
expect(flag1Update.version).toBe(1);
expect(flag1Update.object).toEqual({
key: 'flag1',
on: true,
fallthrough: { variation: 0 },
variations: ['value1'],
version: 1,
});
});

it('loads flagValues alongside flags and segments in the same file', async () => {
const combinedFile = JSON.stringify({
flags: { flag1 },
flagValues: { flag2: 'value2' },
segments: { segment1 },
});
mockFilesystem.fileData['combined.json'] = { timestamp: 0, data: combinedFile };

const options: FileSystemDataSourceConfiguration = {
type: 'file',
paths: ['combined.json'],
};

const initializer = new FileDataInitializerFDv2(options, platform, logger);
initializer.start(mockDataCallback, mockStatusCallback);

await jest.runAllTimersAsync();

const { payload } = mockDataCallback.mock.calls[0][1];
const flagUpdates = payload.updates.filter((update: any) => update.kind === 'flag');
const segmentUpdates = payload.updates.filter((update: any) => update.kind === 'segment');

expect(flagUpdates.length).toBe(2);
expect(segmentUpdates.length).toBe(1);

const flag1Update = flagUpdates.find((update: any) => update.key === 'flag1');
expect(flag1Update.object).toEqual(flag1);

const flag2Update = flagUpdates.find((update: any) => update.key === 'flag2');
expect(flag2Update.version).toBe(1);
expect(flag2Update.object).toEqual({
key: 'flag2',
on: true,
fallthrough: { variation: 0 },
variations: ['value2'],
version: 1,
});

const segment1Update = segmentUpdates.find((update: any) => update.key === 'segment1');
expect(segment1Update.object).toEqual(segment1);
});

it('merges flagValues from multiple files', async () => {
const file1 = JSON.stringify({ flagValues: { flag1: 'value1' } });
const file2 = JSON.stringify({ flagValues: { flag2: 'value2' } });
mockFilesystem.fileData['file1.json'] = { timestamp: 0, data: file1 };
mockFilesystem.fileData['file2.json'] = { timestamp: 0, data: file2 };

const options: FileSystemDataSourceConfiguration = {
type: 'file',
paths: ['file1.json', 'file2.json'],
};

const initializer = new FileDataInitializerFDv2(options, platform, logger);
initializer.start(mockDataCallback, mockStatusCallback);

await jest.runAllTimersAsync();

const { payload } = mockDataCallback.mock.calls[0][1];
const flagUpdates = payload.updates.filter((update: any) => update.kind === 'flag');
expect(flagUpdates.length).toBe(2);

expect(flagUpdates.find((update: any) => update.key === 'flag1').object).toEqual({
key: 'flag1',
on: true,
fallthrough: { variation: 0 },
variations: ['value1'],
version: 1,
});
expect(flagUpdates.find((update: any) => update.key === 'flag2').object).toEqual({
key: 'flag2',
on: true,
fallthrough: { variation: 0 },
variations: ['value2'],
version: 1,
});
});

it('applies last-file-wins when a key appears in flags and flagValues across files', async () => {
// file1 defines flag1 via the full flags key, file2 redefines it via flagValues
const file1 = JSON.stringify({
flags: { flag1: { ...flag1, variations: ['fromFlags'] } },
});
const file2 = JSON.stringify({
flagValues: { flag1: 'fromFlagValues' },
});
mockFilesystem.fileData['file1.json'] = { timestamp: 0, data: file1 };
mockFilesystem.fileData['file2.json'] = { timestamp: 0, data: file2 };

const options: FileSystemDataSourceConfiguration = {
type: 'file',
paths: ['file1.json', 'file2.json'],
};

const initializer = new FileDataInitializerFDv2(options, platform, logger);
initializer.start(mockDataCallback, mockStatusCallback);

await jest.runAllTimersAsync();

const { payload } = mockDataCallback.mock.calls[0][1];
const flagUpdates = payload.updates.filter((update: any) => update.kind === 'flag');

const flag1Updates = flagUpdates.filter((update: any) => update.key === 'flag1');
expect(flag1Updates.length).toBe(1);
expect(flag1Updates[0].object).toEqual({
key: 'flag1',
on: true,
fallthrough: { variation: 0 },
variations: ['fromFlagValues'],
version: 1,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import FileLoader from './FileLoader';

export type FileDataSourceErrorHandler = (err: LDFileDataSourceError) => void;

function makeFlagWithValue(key: string, value: any, version: number): Flag {
export function makeFlagWithValue(key: string, value: any, version: number): Flag {
return {
key,
on: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,22 @@ import { Flag } from '../evaluation/data/Flag';
import { Segment } from '../evaluation/data/Segment';
import { processFlag, processSegment } from '../store/serialization';
import FileLoader from './FileLoader';
import { makeFlagWithValue } from './FileDataSource';

/**
* Loads flag/segment data from one or more files. Each file may contain `flags`
* (full flag JSON) and/or `segments` keys, and also supports the `flagValues`
* shorthand map (`{ [key]: value }`) for quickly defining single-variation flags,
* the same shorthand supported by FDv1's `FileDataSource`.
*
* @remarks
* This initializer runs once at startup and never reloads or diffs against
* previously loaded data, so every flag generated from `flagValues` gets
* `version: 1` - there is no version-bump-on-change behavior like FDv1 has.
* Duplicate keys resolve last-value-wins (across files, and between a `flags`
* and `flagValues` entry for the same key within one file) rather than being
* rejected the way FDv1 does.
*
* @internal
*/
export default class FileDataInitializerFDv2 implements subsystemCommon.DataSource {
Expand Down Expand Up @@ -125,6 +139,15 @@ export default class FileDataInitializerFDv2 implements subsystemCommon.DataSour
} else {
parsed = JSON.parse(curr.data);
}

// flagValues has no previous-state to diff against, so each entry always
// gets version 1. Convert to full Flag objects here so they merge with
// flags below on equal footing
const flagsFromValues: { [key: string]: Flag } = {};
Object.entries(parsed.flagValues ?? {}).forEach(([key, value]) => {
flagsFromValues[key] = makeFlagWithValue(key, value, 1);
});

return {
segments: {
...acc.segments,
Expand All @@ -133,6 +156,7 @@ export default class FileDataInitializerFDv2 implements subsystemCommon.DataSour
flags: {
...acc.flags,
...(parsed.flags ?? {}),
...flagsFromValues,
},
};
},
Expand Down
Loading