diff --git a/CHANGES.txt b/CHANGES.txt index d07f2bc1..4a80a5e8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +2.5.0 (September 10, 2025) + - Added `factory.getRolloutPlan()` method for standalone server-side SDKs, which returns the rollout plan snapshot from the storage. + - Added `initialRolloutPlan` configuration option for standalone client-side SDKs, which allows preloading the SDK storage with a snapshot of the rollout plan. + 2.4.1 (June 3, 2025) - 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. diff --git a/package-lock.json b/package-lock.json index aa7cf6d8..14125ac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", diff --git a/package.json b/package.json index 27b15da2..155f650a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-commons", - "version": "2.4.1", + "version": "2.5.0", "description": "Split JavaScript SDK common components", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/src/sdkClient/sdkClientMethodCS.ts b/src/sdkClient/sdkClientMethodCS.ts index ebc755a1..b68481a9 100644 --- a/src/sdkClient/sdkClientMethodCS.ts +++ b/src/sdkClient/sdkClientMethodCS.ts @@ -9,13 +9,15 @@ import { RETRIEVE_CLIENT_DEFAULT, NEW_SHARED_CLIENT, RETRIEVE_CLIENT_EXISTING, L import { SDK_SEGMENTS_ARRIVED } from '../readiness/constants'; import { ISdkFactoryContext } from '../sdkFactory/types'; import { buildInstanceId } from './identity'; +import { setRolloutPlan } from '../storages/setRolloutPlan'; +import { ISegmentsCacheSync } from '../storages/types'; /** * Factory of client method for the client-side API variant where TT is ignored. * Therefore, clients don't have a bound TT for the track method. */ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: SplitIO.SplitKey) => SplitIO.IBrowserClient { - const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log } } = params; + const { clients, storage, syncManager, sdkReadinessManager, settings: { core: { key }, log, initialRolloutPlan } } = params; const mainClientInstance = clientCSDecorator( log, @@ -56,6 +58,10 @@ export function sdkClientMethodCSFactory(params: ISdkFactoryContext): (key?: Spl sharedSdkReadiness.readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); }); + if (sharedStorage && initialRolloutPlan) { + setRolloutPlan(log, initialRolloutPlan, { segments: sharedStorage.segments as ISegmentsCacheSync, largeSegments: sharedStorage.largeSegments as ISegmentsCacheSync }, matchingKey); + } + // 3 possibilities: // - Standalone mode: both syncManager and sharedSyncManager are defined // - Consumer mode: both syncManager and sharedSyncManager are undefined diff --git a/src/sdkFactory/index.ts b/src/sdkFactory/index.ts index bf807425..d1dcac43 100644 --- a/src/sdkFactory/index.ts +++ b/src/sdkFactory/index.ts @@ -14,6 +14,9 @@ import { strategyOptimizedFactory } from '../trackers/strategy/strategyOptimized import { strategyNoneFactory } from '../trackers/strategy/strategyNone'; import { uniqueKeysTrackerFactory } from '../trackers/uniqueKeysTracker'; import { DEBUG, OPTIMIZED } from '../utils/constants'; +import { setRolloutPlan } from '../storages/setRolloutPlan'; +import { IStorageSync } from '../storages/types'; +import { getMatching } from '../utils/key'; /** * Modular SDK factory @@ -24,7 +27,7 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA syncManagerFactory, SignalListener, impressionsObserverFactory, integrationsManagerFactory, sdkManagerFactory, sdkClientMethodFactory, filterAdapterFactory, lazyInit } = params; - const { log, sync: { impressionsMode } } = settings; + const { log, sync: { impressionsMode }, initialRolloutPlan, core: { key } } = settings; // @TODO handle non-recoverable errors, such as, global `fetch` not available, invalid SDK Key, etc. // 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 const storage = storageFactory({ settings, - onReadyCb: (error) => { + onReadyCb(error) { if (error) { // If storage fails to connect, SDK_READY_TIMED_OUT event is emitted immediately. Review when timeout and non-recoverable errors are reworked readiness.timeout(); @@ -52,11 +55,16 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA readiness.splits.emit(SDK_SPLITS_ARRIVED); readiness.segments.emit(SDK_SEGMENTS_ARRIVED); }, - onReadyFromCacheCb: () => { + onReadyFromCacheCb() { readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); } }); - // @TODO add support for dataloader: `if (params.dataLoader) params.dataLoader(storage);` + + if (initialRolloutPlan) { + setRolloutPlan(log, initialRolloutPlan, storage as IStorageSync, key && getMatching(key)); + if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); + } + const clients: Record = {}; const telemetryTracker = telemetryTrackerFactory(storage.telemetry, platform.now); const integrationsManager = integrationsManagerFactory && integrationsManagerFactory({ settings, storage, telemetryTracker }); diff --git a/src/storages/__tests__/dataLoader.spec.ts b/src/storages/__tests__/dataLoader.spec.ts new file mode 100644 index 00000000..3f1de562 --- /dev/null +++ b/src/storages/__tests__/dataLoader.spec.ts @@ -0,0 +1,133 @@ +import { InMemoryStorageFactory } from '../inMemory/InMemoryStorage'; +import { InMemoryStorageCSFactory } from '../inMemory/InMemoryStorageCS'; +import { fullSettings } from '../../utils/settingsValidation/__tests__/settings.mocks'; +import { loggerMock } from '../../logger/__tests__/sdkLogger.mock'; +import { IRBSegment, ISplit } from '../../dtos/types'; + +import { validateRolloutPlan, setRolloutPlan } from '../setRolloutPlan'; +import { getRolloutPlan } from '../getRolloutPlan'; + +const otherKey = 'otherKey'; +const expectedRolloutPlan = { + splitChanges: { + ff: { d: [{ name: 'split1' }], t: 123, s: -1 }, + rbs: { d: [{ name: 'rbs1' }], t: 321, s: -1 } + }, + memberships: { + [fullSettings.core.key as string]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } }, + [otherKey]: { ms: { k: [{ n: 'segment1' }] }, ls: { k: [] } } + }, + segmentChanges: [{ + name: 'segment1', + added: [fullSettings.core.key as string, otherKey], + removed: [], + since: -1, + till: 123 + }] +}; + +describe('validateRolloutPlan', () => { + afterEach(() => { + loggerMock.mockClear(); + }); + + test('valid rollout plan and mode', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: expectedRolloutPlan } as any)).toEqual(expectedRolloutPlan); + expect(loggerMock.error).not.toHaveBeenCalled(); + }); + + test('invalid rollout plan', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'standalone', initialRolloutPlan: {} } as any)).toBeUndefined(); + expect(loggerMock.error).toHaveBeenCalledWith('storage: invalid rollout plan provided'); + }); + + test('invalid mode', () => { + expect(validateRolloutPlan(loggerMock, { mode: 'consumer', initialRolloutPlan: expectedRolloutPlan } as any)).toBeUndefined(); + expect(loggerMock.warn).toHaveBeenCalledWith('storage: initial rollout plan is ignored in consumer mode'); + }); +}); + +describe('getRolloutPlan & setRolloutPlan (client-side)', () => { + // @ts-expect-error Load server-side storage + const serverStorage = InMemoryStorageFactory({ settings: fullSettings }); + serverStorage.splits.update([{ name: 'split1' } as ISplit], [], 123); + serverStorage.rbSegments.update([{ name: 'rbs1' } as IRBSegment], [], 321); + serverStorage.segments.update('segment1', [fullSettings.core.key as string, otherKey], [], 123); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('using preloaded data (no memberships, no segments)', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual([]); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual([]); + + // Get preloaded data from client-side storage + expect(getRolloutPlan(loggerMock, clientStorage)).toEqual(rolloutPlan); + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined, segmentChanges: undefined }); + }); + + test('using preloaded data with memberships', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string, otherKey] }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + + // @TODO requires internal storage cache for `shared` storages + // // Get preloaded data from client-side storage + // expect(getRolloutPlan(loggerMock, clientStorage, { keys: [fullSettings.core.key as string, otherKey] })).toEqual(rolloutPlan); + // expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, segmentChanges: undefined }); + }); + + test('using preloaded data with segments', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { exposeSegments: true }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); + + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: undefined }); + }); + + test('using preloaded data with memberships and segments', () => { + const rolloutPlan = getRolloutPlan(loggerMock, serverStorage, { keys: [fullSettings.core.key as string], exposeSegments: true }); + + // @ts-expect-error Load client-side storage with preloaded data + const clientStorage = InMemoryStorageCSFactory({ settings: fullSettings }); + setRolloutPlan(loggerMock, rolloutPlan, clientStorage, fullSettings.core.key as string); + + // Shared client storage + const sharedClientStorage = clientStorage.shared!(otherKey); + setRolloutPlan(loggerMock, rolloutPlan, { segments: sharedClientStorage.segments, largeSegments: sharedClientStorage.largeSegments }, otherKey); + + expect(clientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // main client membership is set via the rollout plan `memberships` field + expect(sharedClientStorage.segments.getRegisteredSegments()).toEqual(['segment1']); // shared client membership is set via the rollout plan `segmentChanges` field + + expect(rolloutPlan).toEqual({ ...expectedRolloutPlan, memberships: { [fullSettings.core.key as string]: expectedRolloutPlan.memberships![fullSettings.core.key as string] } }); + }); +}); diff --git a/src/storages/dataLoader.ts b/src/storages/dataLoader.ts deleted file mode 100644 index 49522bce..00000000 --- a/src/storages/dataLoader.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PreloadedData } from '../types'; -import { DataLoader, ISegmentsCacheSync, ISplitsCacheSync } from './types'; - -// This value might be eventually set via a config parameter -const DEFAULT_CACHE_EXPIRATION_IN_MILLIS = 864000000; // 10 days - -/** - * Factory of client-side storage loader - * - * @param preloadedData - validated data following the format proposed in https://github.com/godaddy/split-javascript-data-loader - * and extended with a `mySegmentsData` property. - * @returns function to preload the storage - */ -export function dataLoaderFactory(preloadedData: PreloadedData): DataLoader { - - /** - * Storage-agnostic adaptation of `loadDataIntoLocalStorage` function - * (https://github.com/godaddy/split-javascript-data-loader/blob/master/src/load-data.js) - * - * @param storage - object containing `splits` and `segments` cache (client-side variant) - * @param userId - user key string of the provided MySegmentsCache - */ - // @TODO extend to support SegmentsCache (server-side variant) by making `userId` optional and adding the corresponding logic. - // @TODO extend to load data on shared mySegments storages. Be specific when emitting SDK_READY_FROM_CACHE on shared clients. Maybe the serializer should provide the `useSegments` flag. - return function loadData(storage: { splits: ISplitsCacheSync, segments: ISegmentsCacheSync }, userId: string) { - // Do not load data if current preloadedData is empty - if (Object.keys(preloadedData).length === 0) return; - - const { lastUpdated = -1, segmentsData = {}, since = -1, splitsData = {} } = preloadedData; - - const storedSince = storage.splits.getChangeNumber(); - const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS; - - // Do not load data if current localStorage data is more recent, - // or if its `lastUpdated` timestamp is older than the given `expirationTimestamp`, - if (storedSince > since || lastUpdated < expirationTimestamp) return; - - // cleaning up the localStorage data, since some cached splits might need be part of the preloaded data - storage.splits.clear(); - - // splitsData in an object where the property is the split name and the pertaining value is a stringified json of its data - storage.splits.update(Object.keys(splitsData).map(splitName => JSON.parse(splitsData[splitName])), [], since); - - // add mySegments data - let mySegmentsData = preloadedData.mySegmentsData && preloadedData.mySegmentsData[userId]; - if (!mySegmentsData) { - // segmentsData in an object where the property is the segment name and the pertaining value is a stringified object that contains the `added` array of userIds - mySegmentsData = Object.keys(segmentsData).filter(segmentName => { - const userIds = JSON.parse(segmentsData[segmentName]).added; - return Array.isArray(userIds) && userIds.indexOf(userId) > -1; - }); - } - storage.segments.resetSegments({ k: mySegmentsData.map(s => ({ n: s })) }); - }; -} diff --git a/src/storages/getRolloutPlan.ts b/src/storages/getRolloutPlan.ts new file mode 100644 index 00000000..40e6ea84 --- /dev/null +++ b/src/storages/getRolloutPlan.ts @@ -0,0 +1,72 @@ +import SplitIO from '../../types/splitio'; +import { IStorageSync } from './types'; +import { setToArray } from '../utils/lang/sets'; +import { getMatching } from '../utils/key'; +import { ILogger } from '../logger/types'; +import { RolloutPlan } from './types'; +import { IMembershipsResponse, IMySegmentsResponse } from '../dtos/types'; + +/** + * Gets the rollout plan snapshot from the given synchronous storage. + */ +export function getRolloutPlan(log: ILogger, storage: IStorageSync, options: SplitIO.RolloutPlanOptions = {}): RolloutPlan { + + const { keys, exposeSegments } = options; + const { splits, segments, rbSegments } = storage; + + log.debug(`storage: get feature flags${keys ? `, and memberships for keys: ${keys}` : ''}${exposeSegments ? ', and segments' : ''}`); + + return { + splitChanges: { + ff: { + t: splits.getChangeNumber(), + s: -1, + d: splits.getAll(), + }, + rbs: { + t: rbSegments.getChangeNumber(), + s: -1, + d: rbSegments.getAll(), + } + }, + segmentChanges: exposeSegments ? // @ts-ignore accessing private prop + Object.keys(segments.segmentCache).map(segmentName => ({ + name: segmentName, // @ts-ignore + added: setToArray(segments.segmentCache[segmentName] as Set), + removed: [], + since: -1, + till: segments.getChangeNumber(segmentName)! + })) : + undefined, + memberships: keys ? + keys.reduce>((prev, key) => { + const matchingKey = getMatching(key); + if (storage.shared) { // Client-side segments + const sharedStorage = storage.shared(matchingKey); + prev[matchingKey] = { + ms: { // @ts-ignore + k: Object.keys(sharedStorage.segments.segmentCache).map(segmentName => ({ n: segmentName })), + }, + ls: sharedStorage.largeSegments ? { // @ts-ignore + k: Object.keys(sharedStorage.largeSegments.segmentCache).map(segmentName => ({ n: segmentName })), + } : undefined + }; + } else { // Server-side segments + prev[matchingKey] = { + ms: { // @ts-ignore + k: Object.keys(storage.segments.segmentCache).reduce((prev, segmentName) => { // @ts-ignore + return storage.segments.segmentCache[segmentName].has(matchingKey) ? + prev!.concat({ n: segmentName }) : + prev; + }, []) + }, + ls: { + k: [] + } + }; + } + return prev; + }, {}) : + undefined + }; +} diff --git a/src/storages/inLocalStorage/validateCache.ts b/src/storages/inLocalStorage/validateCache.ts index 93d3144c..3fa54ec6 100644 --- a/src/storages/inLocalStorage/validateCache.ts +++ b/src/storages/inLocalStorage/validateCache.ts @@ -17,7 +17,7 @@ const MILLIS_IN_A_DAY = 86400000; * @returns `true` if cache should be cleared, `false` otherwise */ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) { - const { log } = settings; + const { log, initialRolloutPlan } = settings; // Check expiration const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10); @@ -41,7 +41,7 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: IS } catch (e) { log.error(LOG_PREFIX + e); } - if (isThereCache) { + if (isThereCache && !initialRolloutPlan) { log.info(LOG_PREFIX + 'SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache'); return true; } diff --git a/src/storages/setRolloutPlan.ts b/src/storages/setRolloutPlan.ts new file mode 100644 index 00000000..a8529231 --- /dev/null +++ b/src/storages/setRolloutPlan.ts @@ -0,0 +1,71 @@ +import SplitIO from '../../types/splitio'; +import { IRBSegmentsCacheSync, ISegmentsCacheSync, ISplitsCacheSync } from './types'; +import { ILogger } from '../logger/types'; +import { isObject } from '../utils/lang'; +import { isConsumerMode } from '../utils/settingsValidation/mode'; +import { RolloutPlan } from './types'; + +/** + * Validates if the given rollout plan is valid. + */ +export function validateRolloutPlan(log: ILogger, settings: SplitIO.ISettings): RolloutPlan | undefined { + const { mode, initialRolloutPlan } = settings; + + if (isConsumerMode(mode)) { + log.warn('storage: initial rollout plan is ignored in consumer mode'); + return; + } + + if (isObject(initialRolloutPlan) && isObject((initialRolloutPlan as any).splitChanges)) return initialRolloutPlan as RolloutPlan; + + log.error('storage: invalid rollout plan provided'); + return; +} + +/** + * Sets the given synchronous storage with the provided rollout plan snapshot. + * If `matchingKey` is provided, the storage is handled as a client-side storage (segments and largeSegments are instances of MySegmentsCache). + * Otherwise, the storage is handled as a server-side storage (segments is an instance of SegmentsCache). + */ +export function setRolloutPlan(log: ILogger, rolloutPlan: RolloutPlan, storage: { splits?: ISplitsCacheSync, rbSegments?: IRBSegmentsCacheSync, segments: ISegmentsCacheSync, largeSegments?: ISegmentsCacheSync }, matchingKey?: string) { + const { splits, rbSegments, segments, largeSegments } = storage; + const { splitChanges: { ff, rbs } } = rolloutPlan; + + log.debug(`storage: set feature flags and segments${matchingKey ? ` for key ${matchingKey}` : ''}`); + + if (splits && ff) { + splits.clear(); + splits.update(ff.d, [], ff.t); + } + + if (rbSegments && rbs) { + rbSegments.clear(); + rbSegments.update(rbs.d, [], rbs.t); + } + + const segmentChanges = rolloutPlan.segmentChanges; + if (matchingKey) { // add memberships data (client-side) + let memberships = rolloutPlan.memberships && rolloutPlan.memberships[matchingKey]; + if (!memberships && segmentChanges) { + memberships = { + ms: { + k: segmentChanges.filter(segment => { + return segment.added.indexOf(matchingKey) > -1; + }).map(segment => ({ n: segment.name })) + } + }; + } + + if (memberships) { + if (memberships.ms) segments.resetSegments(memberships.ms!); + if (memberships.ls && largeSegments) largeSegments.resetSegments(memberships.ls!); + } + } else { // add segments data (server-side) + if (segmentChanges) { + segments.clear(); + segmentChanges.forEach(segment => { + segments.update(segment.name, segment.added, segment.removed, segment.till); + }); + } + } +} diff --git a/src/storages/types.ts b/src/storages/types.ts index 97664de5..2737da40 100644 --- a/src/storages/types.ts +++ b/src/storages/types.ts @@ -1,5 +1,5 @@ import SplitIO from '../../types/splitio'; -import { MaybeThenable, ISplit, IRBSegment, IMySegmentsResponse } from '../dtos/types'; +import { MaybeThenable, ISplit, IRBSegment, IMySegmentsResponse, IMembershipsResponse, ISegmentChangesResponse, ISplitChangesResponse } from '../dtos/types'; import { MySegmentsData } from '../sync/polling/types'; import { EventDataType, HttpErrors, HttpLatencies, ImpressionDataType, LastSync, Method, MethodExceptions, MethodLatencies, MultiMethodExceptions, MultiMethodLatencies, MultiConfigs, OperationType, StoredEventWithMetadata, StoredImpressionWithMetadata, StreamingEvent, UniqueKeysPayloadCs, UniqueKeysPayloadSs, TelemetryUsageStatsPayload, UpdatesFromSSEEnum } from '../sync/submitters/types'; import { ISettings } from '../types'; @@ -497,8 +497,6 @@ export interface IStorageAsync extends IStorageBase< /** StorageFactory */ -export type DataLoader = (storage: IStorageSync, matchingKey: string) => void - export interface IStorageFactoryParams { settings: ISettings, /** @@ -522,3 +520,21 @@ export type IStorageAsyncFactory = SplitIO.StorageAsyncFactory & { readonly type: SplitIO.StorageType, (params: IStorageFactoryParams): IStorageAsync } + +export type RolloutPlan = { + /** + * Feature flags and rule-based segments. + */ + splitChanges: ISplitChangesResponse; + /** + * Optional map of matching keys to their memberships. + */ + memberships?: { + [matchingKey: string]: IMembershipsResponse; + }; + /** + * Optional list of standard segments. + * This property is ignored if `memberships` is provided. + */ + segmentChanges?: ISegmentChangesResponse[]; +}; diff --git a/src/types.ts b/src/types.ts index bdb0933c..ad3fa04c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import SplitIO from '../types/splitio'; import { ISplitFiltersValidation } from './dtos/types'; import { ILogger } from './logger/types'; +import { RolloutPlan } from './storages/types'; /** * SplitIO.ISettings interface extended with private properties for internal use @@ -10,6 +11,7 @@ export interface ISettings extends SplitIO.ISettings { __splitFiltersValidation: ISplitFiltersValidation; }; readonly log: ILogger; + readonly initialRolloutPlan?: RolloutPlan; } /** @@ -42,38 +44,3 @@ export interface IBasicClient extends SplitIO.IBasicClient { isClientSide?: boolean; key?: SplitIO.SplitKey; } -/** - * Defines the format of rollout plan data to preload the factory storage (cache). - */ -export interface PreloadedData { - /** - * Timestamp of the last moment the data was synchronized with Split servers. - * If this value is older than 10 days ago (expiration time policy), the data is not used to update the storage content. - */ - // @TODO configurable expiration time policy? - lastUpdated: number; - /** - * Change number of the preloaded data. - * If this value is older than the current changeNumber at the storage, the data is not used to update the storage content. - */ - since: number; - /** - * Map of feature flags to their stringified definitions. - */ - splitsData: { - [splitName: string]: string; - }; - /** - * Optional map of user keys to their list of segments. - */ - mySegmentsData?: { - [key: string]: string[]; - }; - /** - * Optional map of segments to their stringified definitions. - * This property is ignored if `mySegmentsData` was provided. - */ - segmentsData?: { - [segmentName: string]: string; - }; -} diff --git a/src/utils/inputValidation/__tests__/preloadedData.spec.ts b/src/utils/inputValidation/__tests__/preloadedData.spec.ts deleted file mode 100644 index 79f1d1a4..00000000 --- a/src/utils/inputValidation/__tests__/preloadedData.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock'; - -// Import the module mocking the logger. -import { validatePreloadedData } from '../preloadedData'; - -const method = 'some_method'; -const testCases = [ - // valid inputs - { - input: { lastUpdated: 10, since: 10, splitsData: {} }, - output: true, - warn: `${method}: preloadedData.splitsData doesn't contain feature flag definitions.` - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: {} }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: [] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'] } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: {} }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: { some_segment: 'SEGMENT DEFINITION' } }, - output: true - }, - { - input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'], some_other_key: ['some_segment'] }, segmentsData: { some_segment: 'SEGMENT DEFINITION', some_other_segment: 'SEGMENT DEFINITION' } }, - output: true - }, - { - // should be true, even using objects for strings and numbers or having extra properties - input: { ignoredProperty: 'IGNORED', lastUpdated: new Number(10), since: new Number(10), splitsData: { 'some_split': new String('SPLIT DEFINITION') }, mySegmentsData: { some_key: [new String('some_segment')] }, segmentsData: { some_segment: new String('SEGMENT DEFINITION') } }, - output: true - }, - - // invalid inputs - { - // should be false if preloadedData is not an object - input: undefined, - output: false, - error: `${method}: preloadedData must be an object.` - }, - { - // should be false if preloadedData is not an object - input: [], - output: false, - error: `${method}: preloadedData must be an object.` - }, - { - // should be false if lastUpdated property is invalid - input: { lastUpdated: undefined, since: 10, splitsData: {} }, - output: false, - error: `${method}: preloadedData.lastUpdated must be a positive number.` - }, - { - // should be false if lastUpdated property is invalid - input: { lastUpdated: -1, since: 10, splitsData: {} }, - output: false, - error: `${method}: preloadedData.lastUpdated must be a positive number.` - }, - { - // should be false if since property is invalid - input: { lastUpdated: 10, since: undefined, splitsData: {} }, - output: false, - error: `${method}: preloadedData.since must be a positive number.` - }, - { - // should be false if since property is invalid - input: { lastUpdated: 10, since: -1, splitsData: {} }, - output: false, - error: `${method}: preloadedData.since must be a positive number.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: undefined }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if splitsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: undefined } }, - output: false, - error: `${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.` - }, - { - // should be false if mySegmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.` - }, - { - // should be false if mySegmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: { some_key: undefined } }, - output: false, - error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.` - }, - { - // should be false if segmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: ['DEFINITION'] }, - output: false, - error: `${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.` - }, - { - // should be false if segmentsData property is invalid - input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: { some_segment: undefined } }, - output: false, - error: `${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.` - } -]; - -test('INPUT VALIDATION for preloadedData', () => { - - for (let i = 0; i < testCases.length; i++) { - const testCase = testCases[i]; - expect(validatePreloadedData(loggerMock, testCase.input, method)).toBe(testCase.output); - - if (testCase.error) { - expect(loggerMock.error.mock.calls[0]).toEqual([testCase.error]); // Should log the error for the invalid preloadedData. - loggerMock.error.mockClear(); - } else { - expect(loggerMock.error).not.toBeCalled(); // Should not log any error. - } - - if (testCase.warn) { - expect(loggerMock.warn.mock.calls[0]).toEqual([testCase.warn]); // Should log the warning for the given preloadedData. - loggerMock.warn.mockClear(); - } else { - expect(loggerMock.warn).not.toBeCalled(); // Should not log any warning. - } - } -}); diff --git a/src/utils/inputValidation/index.ts b/src/utils/inputValidation/index.ts index 96cf4be6..eac9777d 100644 --- a/src/utils/inputValidation/index.ts +++ b/src/utils/inputValidation/index.ts @@ -10,5 +10,4 @@ export { validateTrafficType } from './trafficType'; export { validateIfNotDestroyed, validateIfOperational } from './isOperational'; export { validateSplitExistence } from './splitExistence'; export { validateTrafficTypeExistence } from './trafficTypeExistence'; -export { validatePreloadedData } from './preloadedData'; export { validateEvaluationOptions } from './eventProperties'; diff --git a/src/utils/inputValidation/preloadedData.ts b/src/utils/inputValidation/preloadedData.ts deleted file mode 100644 index f07ee432..00000000 --- a/src/utils/inputValidation/preloadedData.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isObject, isString, isFiniteNumber } from '../lang'; -import { validateSplit } from './split'; -import { ILogger } from '../../logger/types'; - -function validateTimestampData(log: ILogger, maybeTimestamp: any, method: string, item: string) { - if (isFiniteNumber(maybeTimestamp) && maybeTimestamp > -1) return true; - log.error(`${method}: preloadedData.${item} must be a positive number.`); - return false; -} - -function validateSplitsData(log: ILogger, maybeSplitsData: any, method: string) { - if (isObject(maybeSplitsData)) { - const splitNames = Object.keys(maybeSplitsData); - if (splitNames.length === 0) log.warn(`${method}: preloadedData.splitsData doesn't contain feature flag definitions.`); - // @TODO in the future, consider handling the possibility of having parsed definitions of splits - if (splitNames.every(splitName => validateSplit(log, splitName, method) && isString(maybeSplitsData[splitName]))) return true; - } - log.error(`${method}: preloadedData.splitsData must be a map of feature flag names to their stringified definitions.`); - return false; -} - -function validateMySegmentsData(log: ILogger, maybeMySegmentsData: any, method: string) { - if (isObject(maybeMySegmentsData)) { - const userKeys = Object.keys(maybeMySegmentsData); - if (userKeys.every(userKey => { - const segmentNames = maybeMySegmentsData[userKey]; - // an empty list is valid - return Array.isArray(segmentNames) && segmentNames.every(segmentName => isString(segmentName)); - })) return true; - } - log.error(`${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.`); - return false; -} - -function validateSegmentsData(log: ILogger, maybeSegmentsData: any, method: string) { - if (isObject(maybeSegmentsData)) { - const segmentNames = Object.keys(maybeSegmentsData); - if (segmentNames.every(segmentName => isString(maybeSegmentsData[segmentName]))) return true; - } - log.error(`${method}: preloadedData.segmentsData must be a map of segment names to their stringified definitions.`); - return false; -} - -export function validatePreloadedData(log: ILogger, maybePreloadedData: any, method: string) { - if (!isObject(maybePreloadedData)) { - log.error(`${method}: preloadedData must be an object.`); - } else if ( - validateTimestampData(log, maybePreloadedData.lastUpdated, method, 'lastUpdated') && - validateTimestampData(log, maybePreloadedData.since, method, 'since') && - validateSplitsData(log, maybePreloadedData.splitsData, method) && - (!maybePreloadedData.mySegmentsData || validateMySegmentsData(log, maybePreloadedData.mySegmentsData, method)) && - (!maybePreloadedData.segmentsData || validateSegmentsData(log, maybePreloadedData.segmentsData, method)) - ) { - return true; - } - return false; -} diff --git a/src/utils/settingsValidation/index.ts b/src/utils/settingsValidation/index.ts index 3c7ecfe7..2dc63018 100644 --- a/src/utils/settingsValidation/index.ts +++ b/src/utils/settingsValidation/index.ts @@ -7,6 +7,7 @@ import { ISettingsValidationParams } from './types'; import { ISettings } from '../../types'; import { validateKey } from '../inputValidation/key'; import { ERROR_MIN_CONFIG_PARAM, LOG_PREFIX_CLIENT_INSTANTIATION } from '../../logger/constants'; +import { validateRolloutPlan } from '../../storages/setRolloutPlan'; // Exported for telemetry export const base = { @@ -152,6 +153,9 @@ export function settingsValidation(config: unknown, validationParams: ISettingsV // @ts-ignore, modify readonly prop if (storage) withDefaults.storage = storage(withDefaults); + // @ts-ignore, modify readonly prop + if (withDefaults.initialRolloutPlan) withDefaults.initialRolloutPlan = validateRolloutPlan(log, withDefaults); + // Validate key and TT (for client-side) const maybeKey = withDefaults.core.key; if (validationParams.acceptKey) { diff --git a/types/splitio.d.ts b/types/splitio.d.ts index e85ab01b..2680f8ef 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -350,6 +350,11 @@ interface IClientSideSyncSharedSettings extends IClientSideSharedSettings, ISync * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#localhost-mode} */ features?: SplitIO.MockedFeaturesMap; + /** + * Rollout plan object (i.e., feature flags and segment definitions) to initialize the SDK storage with. If provided and valid, the SDK will be ready from cache immediately. + * This object is derived from calling the Node.js SDK’s `getRolloutPlan` method. + */ + initialRolloutPlan?: SplitIO.RolloutPlan; /** * SDK Startup settings. */ @@ -555,6 +560,7 @@ declare namespace SplitIO { eventsFirstPushWindow: number; }; readonly storage: StorageSyncFactory | StorageAsyncFactory | StorageOptions; + readonly initialRolloutPlan?: SplitIO.RolloutPlan; readonly urls: { events: string; sdk: string; @@ -1020,7 +1026,28 @@ declare namespace SplitIO { type: NodeSyncStorage | NodeAsyncStorage | BrowserStorage; prefix?: string; options?: Object; - } + }; + /** + * A JSON-serializable plain object that defines the format of rollout plan data to preload the SDK cache with feature flags and segments. + */ + type RolloutPlan = Object; + /** + * Options for the `factory.getRolloutPlan` method. + */ + type RolloutPlanOptions = { + /** + * Optional list of keys to generate the rollout plan snapshot with the memberships of the given keys. + * + * @defaultValue `undefined` + */ + keys?: SplitKey[]; + /** + * Optional flag to expose segments data in the rollout plan snapshot. + * + * @defaultValue `false` + */ + exposeSegments?: boolean; + }; /** * Impression listener interface. This is the interface that needs to be implemented * by the element you provide to the SDK as impression listener. @@ -1043,7 +1070,7 @@ declare namespace SplitIO { type IntegrationFactory = { readonly type: string; (params: any): (Integration | void); - } + }; /** * A pair of user key and it's trafficType, required for tracking valid Split events. */ @@ -1564,6 +1591,20 @@ declare namespace SplitIO { * @returns The manager instance. */ manager(): IManager; + /** + * Returns the current snapshot of the SDK rollout plan in cache. + * + * Wait for the SDK client to be ready before calling this method. + * + * ```js + * await factory.client().ready(); + * const rolloutPlan = factory.getRolloutPlan(); + * ``` + * + * @param options - An object of type RolloutPlanOptions for advanced options. + * @returns The current snapshot of the SDK rollout plan. + */ + getRolloutPlan(options?: RolloutPlanOptions): RolloutPlan; } /** * This represents the interface for the SDK instance for server-side with asynchronous storage.