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
2 changes: 1 addition & 1 deletion CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
2.11.0 (January XX, 2026)
- Added metadata to SDK_UPDATE events to indicate the type of update (FLAGS_UPDATE or SEGMENTS_UPDATE) and the names of updated flags or segments.
- Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean: `true` for fresh install/first app launch, `false` for warm cache/second app launch) and `lastUpdateTimestamp` (Int64 milliseconds since epoch).
- Added metadata to SDK_READY and SDK_READY_FROM_CACHE events, including `initialCacheLoad` (boolean: `true` for fresh install/first app launch, `false` for warm cache/second app launch) and `lastUpdateTimestamp` (milliseconds since epoch).

2.10.1 (December 18, 2025)
- Bugfix - Handle `null` prerequisites properly.
Expand Down
18 changes: 9 additions & 9 deletions src/readiness/__tests__/readinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ test('READINESS MANAGER / Ready from cache event should be fired once', (done) =
counter++;
});

readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
setTimeout(() => {
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
}, 0);
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: undefined });

setTimeout(() => {
expect(counter).toBe(1); // should be called only once
Expand Down Expand Up @@ -372,7 +372,7 @@ test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when ca

// Emit cache loaded event with timestamp
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, {
isCacheValid: true,
initialCacheLoad: false,
lastUpdateTimestamp: cacheTimestamp
});

Expand All @@ -395,15 +395,15 @@ test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SD

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true);
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull();
expect(receivedMetadata!.lastUpdateTimestamp).toBeUndefined();
});

test('READINESS MANAGER / SDK_READY should emit with metadata when ready from cache', () => {
const readinessManager = readinessManagerFactory(EventEmitter, settings);

const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
// First emit cache loaded with timestamp
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: cacheTimestamp });
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: cacheTimestamp });

let receivedMetadata: SdkReadyMetadata | undefined;
readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => {
Expand Down Expand Up @@ -433,5 +433,5 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready without

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was not ready from cache
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull(); // No cache timestamp when fresh install
expect(receivedMetadata!.lastUpdateTimestamp).toBeUndefined(); // No cache timestamp when fresh install
});
2 changes: 1 addition & 1 deletion src/readiness/__tests__/sdkReadinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ describe('SDK Readiness Manager - Promises', () => {
const sdkReadinessManager = sdkReadinessManagerFactory(EventEmitter, fullSettings);

// make the SDK ready from cache
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false, lastUpdateTimestamp: null });
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false);

// validate error log for SDK_READY_FROM_CACHE
Expand Down
22 changes: 9 additions & 13 deletions src/readiness/readinessManager.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { objectAssign } from '../utils/lang/objectAssign';
import { ISettings } from '../types';
import SplitIO from '../../types/splitio';
import SplitIO, { SdkReadyMetadata } from '../../types/splitio';
import { SDK_SPLITS_ARRIVED, SDK_SPLITS_CACHE_LOADED, SDK_SEGMENTS_ARRIVED, SDK_READY_TIMED_OUT, SDK_READY_FROM_CACHE, SDK_UPDATE, SDK_READY } from './constants';
import { IReadinessEventEmitter, IReadinessManager, ISegmentsEventEmitter, ISplitsEventEmitter } from './types';
import { CacheValidationMetadata } from '../storages/inLocalStorage/validateCache';

function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter {
const splitsEventEmitter = objectAssign(new EventEmitter(), {
Expand Down Expand Up @@ -56,7 +55,7 @@ export function readinessManagerFactory(

// emit SDK_READY_FROM_CACHE
let isReadyFromCache = false;
let cacheLastUpdateTimestamp: number | null = null;
let cacheLastUpdateTimestamp: number | undefined = undefined;
if (splits.splitsCacheLoaded) isReadyFromCache = true; // ready from cache, but doesn't emit SDK_READY_FROM_CACHE
else splits.once(SDK_SPLITS_CACHE_LOADED, checkIsReadyFromCache);

Expand Down Expand Up @@ -86,17 +85,14 @@ export function readinessManagerFactory(
splits.initCallbacks.push(__init);
if (splits.hasInit) __init();

function checkIsReadyFromCache(cacheMetadata: CacheValidationMetadata) {
isReadyFromCache = true;
function checkIsReadyFromCache(cacheMetadata: SdkReadyMetadata) {
cacheLastUpdateTimestamp = cacheMetadata.lastUpdateTimestamp;
isReadyFromCache = true;
// Don't emit SDK_READY_FROM_CACHE if SDK_READY has been emitted
if (!isReady && !isDestroyed) {
try {
syncLastUpdate();
gate.emit(SDK_READY_FROM_CACHE, {
initialCacheLoad: !cacheMetadata.isCacheValid,
lastUpdateTimestamp: cacheLastUpdateTimestamp
});
gate.emit(SDK_READY_FROM_CACHE, cacheMetadata);
} catch (e) {
// throws user callback exceptions in next tick
setTimeout(() => { throw e; }, 0);
Expand Down Expand Up @@ -124,14 +120,14 @@ export function readinessManagerFactory(
if (!isReadyFromCache) {
isReadyFromCache = true;
const metadataReadyFromCache: SplitIO.SdkReadyMetadata = {
initialCacheLoad: true,
lastUpdateTimestamp: null // No cache timestamp when fresh install
initialCacheLoad: true, // Fresh install, no cache existed
lastUpdateTimestamp: undefined // No cache timestamp when fresh install
};
gate.emit(SDK_READY_FROM_CACHE, metadataReadyFromCache);
}
const metadataReady: SplitIO.SdkReadyMetadata = {
initialCacheLoad: !wasReadyFromCache,
lastUpdateTimestamp: wasReadyFromCache ? cacheLastUpdateTimestamp : null
initialCacheLoad: !wasReadyFromCache, // true if not ready from cache (initial load), false if ready from cache
lastUpdateTimestamp: wasReadyFromCache ? cacheLastUpdateTimestamp : undefined
};
gate.emit(SDK_READY, metadataReady);
} catch (e) {
Expand Down
6 changes: 3 additions & 3 deletions src/sdkFactory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ export function sdkFactory(params: ISdkFactoryParams): SplitIO.ISDK | SplitIO.IA
return;
}
readiness.splits.emit(SDK_SPLITS_ARRIVED);
readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { isCacheValid: true, lastUpdateTimestamp: null });
readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
},
onReadyFromCacheCb() {
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
}
});

const fallbackTreatmentsCalculator = new FallbackTreatmentsCalculator(settings.fallbackTreatments);

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, { isCacheValid: true, lastUpdateTimestamp: null });
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { initialCacheLoad: false /* Not an initial load, cache exists */ });
}

const clients: Record<string, SplitIO.IBasicClient> = {};
Expand Down
34 changes: 17 additions & 17 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ describe.each(storages)('validateCache', (storage) => {
for (let i = 0; i < storage.length; i++) storage.removeItem(storage.key(i) as string);
});

test('if there is no cache, it should return false', async () => {
test('if there is no cache, it should return initialCacheLoad: true', async () => {
const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.isCacheValid).toBe(false);
expect(result.lastUpdateTimestamp).toBeNull();
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -46,15 +46,15 @@ describe.each(storages)('validateCache', (storage) => {
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it must not be cleared, it should return true', async () => {
test('if there is cache and it must not be cleared, it should return initialCacheLoad: false', async () => {
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
await storage.save && storage.save();

const result = await validateCache({}, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.isCacheValid).toBe(true);
expect(result.initialCacheLoad).toBe(false);
expect(result.lastUpdateTimestamp).toBe(lastUpdateTimestamp);

expect(logSpy).not.toHaveBeenCalled();
Expand All @@ -69,15 +69,15 @@ describe.each(storages)('validateCache', (storage) => {
expect(storage.getItem(keys.buildLastClear())).toBeNull();
});

test('if there is cache and it has expired, it should clear cache and return false', async () => {
test('if there is cache and it has expired, it should clear cache and return initialCacheLoad: true', async () => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
await storage.save && storage.save();

const result = await validateCache({ expirationDays: 1 }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.isCacheValid).toBe(false);
expect(result.lastUpdateTimestamp).toBeNull();
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');

Expand All @@ -90,14 +90,14 @@ describe.each(storages)('validateCache', (storage) => {
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and its hash has changed, it should clear cache and return false', async () => {
test('if there is cache and its hash has changed, it should clear cache and return initialCacheLoad: true', async () => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

const result = await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments);
expect(result.isCacheValid).toBe(false);
expect(result.lastUpdateTimestamp).toBeNull();
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');

Expand All @@ -110,16 +110,16 @@ describe.each(storages)('validateCache', (storage) => {
expect(nearlyEqual(parseInt(storage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
});

test('if there is cache and clearOnInit is true, it should clear cache and return false', async () => {
test('if there is cache and clearOnInit is true, it should clear cache and return initialCacheLoad: true', async () => {
// Older cache version (without last clear)
storage.removeItem(keys.buildLastClear());
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

const result = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result.isCacheValid).toBe(false);
expect(result.lastUpdateTimestamp).toBeNull();
expect(result.initialCacheLoad).toBe(true);
expect(result.lastUpdateTimestamp).toBeUndefined();

expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');

Expand All @@ -138,16 +138,16 @@ describe.each(storages)('validateCache', (storage) => {
storage.setItem(keys.buildSplitsTillKey(), '1');
storage.setItem(keys.buildLastUpdatedKey(), lastUpdateTimestamp + '');
const result2 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result2.isCacheValid).toBe(true);
expect(result2.initialCacheLoad).toBe(false);
expect(result2.lastUpdateTimestamp).toBe(lastUpdateTimestamp);
expect(logSpy).not.toHaveBeenCalled();
expect(storage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed

// If a day has passed, it should clear again
storage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
const result3 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result3.isCacheValid).toBe(false);
expect(result3.lastUpdateTimestamp).toBeNull();
expect(result3.initialCacheLoad).toBe(true);
expect(result3.lastUpdateTimestamp).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
expect(splits.clear).toHaveBeenCalledTimes(2);
expect(rbSegments.clear).toHaveBeenCalledTimes(2);
Expand Down
4 changes: 2 additions & 2 deletions src/storages/inLocalStorage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { STORAGE_LOCALSTORAGE } from '../../utils/constants';
import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/TelemetryCacheInMemory';
import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS';
import { getMatching } from '../../utils/key';
import { validateCache, CacheValidationMetadata } from './validateCache';
import { validateCache } from './validateCache';
import { ILogger } from '../../logger/types';
import SplitIO from '../../../types/splitio';
import { storageAdapter } from './storageAdapter';
Expand Down Expand Up @@ -54,7 +54,7 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
const rbSegments = new RBSegmentsCacheInLocal(settings, keys, storage);
const segments = new MySegmentsCacheInLocal(log, keys, storage);
const largeSegments = new MySegmentsCacheInLocal(log, myLargeSegmentsKeyBuilder(prefix, matchingKey), storage);
let validateCachePromise: Promise<CacheValidationMetadata> | undefined;
let validateCachePromise: Promise<SplitIO.SdkReadyMetadata> | undefined;

return {
splits,
Expand Down
19 changes: 7 additions & 12 deletions src/storages/inLocalStorage/validateCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ import { StorageAdapter } from '../types';
const DEFAULT_CACHE_EXPIRATION_IN_DAYS = 10;
const MILLIS_IN_A_DAY = 86400000;

export interface CacheValidationMetadata {
isCacheValid: boolean;
lastUpdateTimestamp: number | null;
}

/**
* Validates if cache should be cleared and sets the cache `hash` if needed.
*
Expand Down Expand Up @@ -71,18 +66,18 @@ function validateExpiration(options: SplitIO.InLocalStorageOptions, storage: Sto
* - its hash has changed, i.e., the SDK key, flags filter criteria or flags spec version was modified
* - `clearOnInit` was set and cache was not cleared in the last 24 hours
*
* @returns Metadata object with `isCacheValid` (true if cache is ready to be used, false otherwise) and `lastUpdateTimestamp` (timestamp of last cache update or null)
* @returns Metadata object with `initialCacheLoad` (true if is fresh install, false if is ready from cache) and `lastUpdateTimestamp` (timestamp of last cache update or undefined)
*/
export function validateCache(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise<CacheValidationMetadata> {
export function validateCache(options: SplitIO.InLocalStorageOptions, storage: StorageAdapter, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, rbSegments: RBSegmentsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): Promise<SplitIO.SdkReadyMetadata> {

return Promise.resolve(storage.load && storage.load()).then(() => {
const currentTimestamp = Date.now();
const isThereCache = splits.getChangeNumber() > -1;

// Get lastUpdateTimestamp from storage
const lastUpdatedTimestampStr = storage.getItem(keys.buildLastUpdatedKey());
const lastUpdatedTimestamp = lastUpdatedTimestampStr ? parseInt(lastUpdatedTimestampStr, 10) : null;
const lastUpdateTimestamp = (!isNaNNumber(lastUpdatedTimestamp) && lastUpdatedTimestamp !== null) ? lastUpdatedTimestamp : null;
const lastUpdatedTimestamp = lastUpdatedTimestampStr ? parseInt(lastUpdatedTimestampStr, 10) : undefined;
const lastUpdateTimestamp = (!isNaNNumber(lastUpdatedTimestamp) && lastUpdatedTimestamp !== undefined) ? lastUpdatedTimestamp : undefined;

if (validateExpiration(options, storage, settings, keys, currentTimestamp, isThereCache)) {
splits.clear();
Expand All @@ -101,14 +96,14 @@ export function validateCache(options: SplitIO.InLocalStorageOptions, storage: S
if (storage.save) storage.save();

return {
isCacheValid: false,
lastUpdateTimestamp: null
initialCacheLoad: true, // Cache was cleared, so this is an initial load (no cache existed)
lastUpdateTimestamp: undefined
};
}

// Check if ready from cache
return {
isCacheValid: isThereCache,
initialCacheLoad: !isThereCache, // true if no cache exists (initial load), false if cache exists (ready from cache)
lastUpdateTimestamp
};
});
Expand Down
2 changes: 1 addition & 1 deletion src/storages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ export interface IStorageSync extends IStorageBase<
IUniqueKeysCacheSync
> {
// Defined in client-side
validateCache?: () => Promise<{ isCacheValid: boolean; lastUpdateTimestamp: number | null }>,
validateCache?: () => Promise<SplitIO.SdkReadyMetadata>,
largeSegments?: ISegmentsCacheSync,
}

Expand Down
Loading