Skip to content

Commit 8771706

Browse files
Merge pull request #427 from splitio/FME-9871-data-loader-utils
[FME-9871] Add data loader utils: getRolloutPlan, setRolloutPlan, validateRolloutPlan
2 parents c5a2867 + acfbc5b commit 8771706

File tree

17 files changed

+372
-320
lines changed

17 files changed

+372
-320
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
2.5.0 (September 10, 2025)
2+
- Added `factory.getRolloutPlan()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage.
3+
- Added `initialRolloutPlan` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan.
4+
15
2.4.1 (June 3, 2025)
26
- Bugfix - Improved the Proxy fallback to flag spec version 1.2 to handle cases where the Proxy does not return an end-of-stream marker in 400 status code responses.
37

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.4.1",
3+
"version": "2.5.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",

src/sdkClient/sdkClientMethodCS.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING, L
99
import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants';
1010
import { ISdkFactoryContext } from '../sdkFactory/types';
1111
import { buildInstanceId } from './identity';
12+
import { setRolloutPlan } from '../storages/setRolloutPlan';
13+
import { ISegmentsCacheSync } from '../storages/types';
1214

1315
/**
1416
* Factory of client method for the client-side API variant where TT is ignored.
1517
* Therefore, clients don't have a bound TT for the track method.
1618
*/
1719
export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey) => SplitIO.IBrowserClient {
18-
const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log } } = params;
20+
const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log, initialRolloutPlan } } = params;
1921

2022
const mainClientInstance = clientCSDecorator(
2123
log,
@@ -56,6 +58,10 @@ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: Spl
5658
sharedSdkReadiness.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);
5759
});
5860

61+
if (sharedStorage && initialRolloutPlan) {
62+
setRolloutPlan(log, initialRolloutPlan, { segments: sharedStorage.segments as ISegmentsCacheSync, largeSegments: sharedStorage.largeSegments as ISegmentsCacheSync }, matchingKey);
63+
}
64+
5965
// 3 possibilities:
6066
// - Standalone mode: both syncManager and sharedSyncManager are defined
6167
// - Consumer mode: both syncManager and sharedSyncManager are undefined

src/sdkFactory/index.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { strategyOptimizedFactory } from '../trackers/strategy/strategyOptimized
1414
import { strategyNoneFactory } from '../trackers/strategy/strategyNone';
1515
import { uniqueKeysTrackerFactory } from '../trackers/uniqueKeysTracker';
1616
import { DEBUG, OPTIMIZED } from '../utils/constants';
17+
import { setRolloutPlan } from '../storages/setRolloutPlan';
18+
import { IStorageSync } from '../storages/types';
19+
import { getMatching } from '../utils/key';
1720

1821
/**
1922
* Modular SDK factory
@@ -24,7 +27,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
2427
syncManagerFactory, SignalListener, impressionsObserverFactory,
2528
integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory,
2629
filterAdapterFactory, lazyInit } = params;
27-
const { log, sync: { impressionsMode } } = settings;
30+
const { log, sync: { impressionsMode }, initialRolloutPlan, core: { key } } = settings;
2831

2932
// @TODO handle non-recoverable errors, such as, global `fetch` not available, invalid SDK Key, etc.
3033
// On non-recoverable errors, we should mark the SDK as destroyed and not start synchronization.
@@ -43,7 +46,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
4346

4447
const storage = storageFactory({
4548
settings,
46-
onReadyCb: (error) => {
49+
onReadyCb(error) {
4750
if (error) {
4851
// If storage fails to connect, SDK_READY_TIMED_OUT event is emitted immediately. Review when timeout and non-recoverable errors are reworked
4952
readiness.timeout();
@@ -52,11 +55,16 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
5255
readiness.splits.emit(SDK_SPLITS_ARRIVED);
5356
readiness.segments.emit(SDK_SEGMENTS_ARRIVED);
5457
},
55-
onReadyFromCacheCb: () => {
58+
onReadyFromCacheCb() {
5659
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
5760
}
5861
});
59-
// @TODO add support for dataloader: `if (params.dataLoader) params.dataLoader(storage);`
62+
63+
if (initialRolloutPlan) {
64+
setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key));
65+
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
66+
}
67+
6068
const clients: Record<string, SplitIO.IBasicClient> = {};
6169
const telemetryTracker = telemetryTrackerFactory(storage.telemetry, platform.now);
6270
const integrationsManager = integrationsManagerFactory && integrationsManagerFactory({ settings, storage, telemetryTracker });
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { InMemoryStorageFactory } from '../inMemory/InMemoryStorage';
2+
import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS';
3+
import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks';
4+
import { loggerMock } from '../../logger/__tests__/sdkLogger.mock';
5+
import { IRBSegment, ISplit } from '../../dtos/types';
6+
7+
import { validateRolloutPlan, setRolloutPlan } from '../setRolloutPlan';
8+
import { getRolloutPlan } from '../getRolloutPlan';
9+
10+
const otherKey = 'otherKey';
11+
const expectedRolloutPlan = {
12+
splitChanges: {
13+
ff: { d: [{ name: 'split1' }], t: 123, s: -1 },
14+
rbs: { d: [{ name: 'rbs1' }], t: 321, s: -1 }
15+
},
16+
memberships: {
17+
[fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } },
18+
[otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }
19+
},
20+
segmentChanges: [{
21+
name: 'segment1',
22+
added: [fullSettings.core.key as string, otherKey],
23+
removed: [],
24+
since: -1,
25+
till: 123
26+
}]
27+
};
28+
29+
describe('validateRolloutPlan', () => {
30+
afterEach(() => {
31+
loggerMock.mockClear();
32+
});
33+
34+
test('valid rollout plan and mode', () => {
35+
expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: expectedRolloutPlan } as any)).toEqual(expectedRolloutPlan);
36+
expect(loggerMock.error).not.toHaveBeenCalled();
37+
});
38+
39+
test('invalid rollout plan', () => {
40+
expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: {} } as any)).toBeUndefined();
41+
expect(loggerMock.error).toHaveBeenCalledWith('storage: invalid rollout plan provided');
42+
});
43+
44+
test('invalid mode', () => {
45+
expect(validateRolloutPlan(loggerMock, { mode: 'consumer', initialRolloutPlan: expectedRolloutPlan } as any)).toBeUndefined();
46+
expect(loggerMock.warn).toHaveBeenCalledWith('storage: initial rollout plan is ignored in consumer mode');
47+
});
48+
});
49+
50+
describe('getRolloutPlan & setRolloutPlan (client-side)', () => {
51+
// @ts-expect-error Load server-side storage
52+
const serverStorage = InMemoryStorageFactory({ settings: fullSettings });
53+
serverStorage.splits.update([{ name: 'split1' } as ISplit], [], 123);
54+
serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321);
55+
serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123);
56+
57+
afterEach(() => {
58+
jest.clearAllMocks();
59+
});
60+
61+
test('using preloaded data (no memberships, no segments)', () => {
62+
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage);
63+
64+
// @ts-expect-error Load client-side storage with preloaded data
65+
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
66+
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);
67+
68+
// Shared client storage
69+
const sharedClientStorage = clientStorage.shared!(otherKey);
70+
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);
71+
72+
expect(clientStorage.segments.getRegisteredSegments()).toEqual([]);
73+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual([]);
74+
75+
// Get preloaded data from client-side storage
76+
expect(getRolloutPlan(loggerMock, clientStorage)).toEqual(rolloutPlan);
77+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined, segmentChanges: undefined });
78+
});
79+
80+
test('using preloaded data with memberships', () => {
81+
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string, otherKey] });
82+
83+
// @ts-expect-error Load client-side storage with preloaded data
84+
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
85+
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);
86+
87+
// Shared client storage
88+
const sharedClientStorage = clientStorage.shared!(otherKey);
89+
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);
90+
91+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
92+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
93+
94+
// @TODO requires internal storage cache for `shared` storages
95+
// // Get preloaded data from client-side storage
96+
// expect(getRolloutPlan(loggerMock, clientStorage, { keys: [fullSettings.core.key as string, otherKey] })).toEqual(rolloutPlan);
97+
// expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, segmentChanges: undefined });
98+
});
99+
100+
test('using preloaded data with segments', () => {
101+
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { exposeSegments: true });
102+
103+
// @ts-expect-error Load client-side storage with preloaded data
104+
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
105+
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);
106+
107+
// Shared client storage
108+
const sharedClientStorage = clientStorage.shared!(otherKey);
109+
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);
110+
111+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
112+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']);
113+
114+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined });
115+
});
116+
117+
test('using preloaded data with memberships and segments', () => {
118+
const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string], exposeSegments: true });
119+
120+
// @ts-expect-error Load client-side storage with preloaded data
121+
const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings });
122+
setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string);
123+
124+
// Shared client storage
125+
const sharedClientStorage = clientStorage.shared!(otherKey);
126+
setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey);
127+
128+
expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // main client membership is set via the rollout plan `memberships` field
129+
expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // shared client membership is set via the rollout plan `segmentChanges` field
130+
131+
expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: { [fullSettings.core.key as string]: expectedRolloutPlan.memberships![fullSettings.core.key as string] } });
132+
});
133+
});

src/storages/dataLoader.ts

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/storages/getRolloutPlan.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import SplitIO from '../../types/splitio';
2+
import { IStorageSync } from './types';
3+
import { setToArray } from '../utils/lang/sets';
4+
import { getMatching } from '../utils/key';
5+
import { ILogger } from '../logger/types';
6+
import { RolloutPlan } from './types';
7+
import { IMembershipsResponse, IMySegmentsResponse } from '../dtos/types';
8+
9+
/**
10+
* Gets the rollout plan snapshot from the given synchronous storage.
11+
*/
12+
export function getRolloutPlan(log: ILogger, storage: IStorageSync, options: SplitIO.RolloutPlanOptions = {}): RolloutPlan {
13+
14+
const { keys, exposeSegments } = options;
15+
const { splits, segments, rbSegments } = storage;
16+
17+
log.debug(`storage: get feature flags${keys ? `, and memberships for keys: ${keys}` : ''}${exposeSegments ? ', and segments' : ''}`);
18+
19+
return {
20+
splitChanges: {
21+
ff: {
22+
t: splits.getChangeNumber(),
23+
s: -1,
24+
d: splits.getAll(),
25+
},
26+
rbs: {
27+
t: rbSegments.getChangeNumber(),
28+
s: -1,
29+
d: rbSegments.getAll(),
30+
}
31+
},
32+
segmentChanges: exposeSegments ? // @ts-ignore accessing private prop
33+
Object.keys(segments.segmentCache).map(segmentName => ({
34+
name: segmentName, // @ts-ignore
35+
added: setToArray(segments.segmentCache[segmentName] as Set<string>),
36+
removed: [],
37+
since: -1,
38+
till: segments.getChangeNumber(segmentName)!
39+
})) :
40+
undefined,
41+
memberships: keys ?
42+
keys.reduce<Record<string, IMembershipsResponse>>((prev, key) => {
43+
const matchingKey = getMatching(key);
44+
if (storage.shared) { // Client-side segments
45+
const sharedStorage = storage.shared(matchingKey);
46+
prev[matchingKey] = {
47+
ms: { // @ts-ignore
48+
k: Object.keys(sharedStorage.segments.segmentCache).map(segmentName => ({ n: segmentName })),
49+
},
50+
ls: sharedStorage.largeSegments ? { // @ts-ignore
51+
k: Object.keys(sharedStorage.largeSegments.segmentCache).map(segmentName => ({ n: segmentName })),
52+
} : undefined
53+
};
54+
} else { // Server-side segments
55+
prev[matchingKey] = {
56+
ms: { // @ts-ignore
57+
k: Object.keys(storage.segments.segmentCache).reduce<IMySegmentsResponse['k']>((prev, segmentName) => { // @ts-ignore
58+
return storage.segments.segmentCache[segmentName].has(matchingKey) ?
59+
prev!.concat({ n: segmentName }) :
60+
prev;
61+
}, [])
62+
},
63+
ls: {
64+
k: []
65+
}
66+
};
67+
}
68+
return prev;
69+
}, {}) :
70+
undefined
71+
};
72+
}

src/storages/inLocalStorage/validateCache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000;
1717
* @returns `true` if cache should be cleared, `false` otherwise
1818
*/
1919
function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) {
20-
const { log } = settings;
20+
const { log, initialRolloutPlan } = settings;
2121

2222
// Check expiration
2323
const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10);
@@ -41,7 +41,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS
4141
} catch (e) {
4242
log.error(LOG_PREFIX + e);
4343
}
44-
if (isThereCache) {
44+
if (isThereCache && !initialRolloutPlan) {
4545
log.info(LOG_PREFIX + 'SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
4646
return true;
4747
}

0 commit comments

Comments
 (0)