diff --git a/packages/shared/sdk-server/__tests__/data_sources/fileDataInitilizerFDv2.test.ts b/packages/shared/sdk-server/__tests__/data_sources/fileDataInitilizerFDv2.test.ts index 0cf626736b..d80355ce98 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/fileDataInitilizerFDv2.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/fileDataInitilizerFDv2.test.ts @@ -74,6 +74,12 @@ const allPropertiesJson = JSON.stringify({ }, }); +const flagValuesOnlyJson = JSON.stringify({ + flagValues: { + flag1: 'value1', + }, +}); + describe('FileDataInitializerFDv2', () => { let mockFilesystem: MockFilesystem; let logger: TestLogger; @@ -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, + }); + }); }); diff --git a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts index 0422a4d343..6c50672bae 100644 --- a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts +++ b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts @@ -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, diff --git a/packages/shared/sdk-server/src/data_sources/fileDataInitilizerFDv2.ts b/packages/shared/sdk-server/src/data_sources/fileDataInitilizerFDv2.ts index 4e40cac96e..47ef15ff52 100644 --- a/packages/shared/sdk-server/src/data_sources/fileDataInitilizerFDv2.ts +++ b/packages/shared/sdk-server/src/data_sources/fileDataInitilizerFDv2.ts @@ -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 { @@ -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, @@ -133,6 +156,7 @@ export default class FileDataInitializerFDv2 implements subsystemCommon.DataSour flags: { ...acc.flags, ...(parsed.flags ?? {}), + ...flagsFromValues, }, }; },