Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 indicating if SDK was loaded from cache) 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` (Int64 milliseconds since epoch).
Comment thread
ZamoraEmmanuel marked this conversation as resolved.
Outdated

2.10.1 (December 18, 2025)
- Bugfix - Handle `null` prerequisites properly.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@splitsoftware/splitio-commons",
"version": "2.10.2-rc.4",
"version": "2.10.2-rc.5",
"description": "Split JavaScript SDK common components",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
49 changes: 22 additions & 27 deletions src/readiness/__tests__/readinessManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,13 @@ test('READINESS MANAGER / Ready from cache event should be fired once', (done) =
counter++;
});

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

setTimeout(() => {
expect(counter).toBe(1); // should be called only once
Expand Down Expand Up @@ -366,19 +364,21 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', ()
test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when cache is loaded', () => {
const readinessManager = readinessManagerFactory(EventEmitter, settings);

const cacheTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
let receivedMetadata: SdkReadyMetadata | undefined;
readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => {
receivedMetadata = meta;
});

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

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true);
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(false);
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
});

test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SDK becomes ready without cache', () => {
Expand All @@ -394,17 +394,16 @@ test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SD
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(false);
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(true);
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull();
});

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

// First emit cache loaded
readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED);
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 });

let receivedMetadata: SdkReadyMetadata | undefined;
readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => {
Expand All @@ -416,10 +415,8 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready from ca
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was ready from cache first
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was ready from cache first
expect(receivedMetadata!.lastUpdateTimestamp).toBe(cacheTimestamp);
});

test('READINESS MANAGER / SDK_READY should emit with metadata when ready without cache', () => {
Expand All @@ -435,8 +432,6 @@ test('READINESS MANAGER / SDK_READY should emit with metadata when ready without
readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED);

expect(receivedMetadata).toBeDefined();
expect(receivedMetadata!.initialCacheLoad).toBe(false); // Was not ready from cache
expect(receivedMetadata!.lastUpdateTimestamp).toBeGreaterThan(0);
// Allow small timing difference (up to 10ms)
expect(receivedMetadata!.lastUpdateTimestamp).toBeLessThanOrEqual(Date.now() + 10);
expect(receivedMetadata!.initialCacheLoad).toBe(true); // Was not ready from cache
expect(receivedMetadata!.lastUpdateTimestamp).toBeNull(); // 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);
sdkReadinessManager.readinessManager.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
expect(await sdkReadinessManager.sdkStatus.whenReadyFromCache()).toBe(false);

// validate error log for SDK_READY_FROM_CACHE
Expand Down
26 changes: 14 additions & 12 deletions src/readiness/readinessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ISettings } from '../types';
import SplitIO 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 @@ -55,6 +56,7 @@ export function readinessManagerFactory(

// emit SDK_READY_FROM_CACHE
let isReadyFromCache = false;
let cacheLastUpdateTimestamp: number | null = null;
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 @@ -84,17 +86,17 @@ export function readinessManagerFactory(
splits.initCallbacks.push(__init);
if (splits.hasInit) __init();

function checkIsReadyFromCache() {
function checkIsReadyFromCache(cacheMetadata: CacheValidationMetadata) {
isReadyFromCache = true;
cacheLastUpdateTimestamp = cacheMetadata.lastUpdateTimestamp;
// Don't emit SDK_READY_FROM_CACHE if SDK_READY has been emitted
if (!isReady && !isDestroyed) {
try {
syncLastUpdate();
const metadata: SplitIO.SdkReadyMetadata = {
initialCacheLoad: true,
lastUpdateTimestamp: lastUpdate
};
gate.emit(SDK_READY_FROM_CACHE, metadata);
gate.emit(SDK_READY_FROM_CACHE, {
initialCacheLoad: !cacheMetadata.isCacheValid,
lastUpdateTimestamp: cacheLastUpdateTimestamp
});
} catch (e) {
// throws user callback exceptions in next tick
setTimeout(() => { throw e; }, 0);
Expand All @@ -121,15 +123,15 @@ export function readinessManagerFactory(
const wasReadyFromCache = isReadyFromCache;
if (!isReadyFromCache) {
isReadyFromCache = true;
const metadataFromCache: SplitIO.SdkReadyMetadata = {
initialCacheLoad: false,
lastUpdateTimestamp: lastUpdate
const metadataReadyFromCache: SplitIO.SdkReadyMetadata = {
initialCacheLoad: true,
lastUpdateTimestamp: null // No cache timestamp when fresh install
};
gate.emit(SDK_READY_FROM_CACHE, metadataFromCache);
gate.emit(SDK_READY_FROM_CACHE, metadataReadyFromCache);
}
const metadataReady: SplitIO.SdkReadyMetadata = {
initialCacheLoad: wasReadyFromCache,
lastUpdateTimestamp: lastUpdate
initialCacheLoad: !wasReadyFromCache,
lastUpdateTimestamp: wasReadyFromCache ? cacheLastUpdateTimestamp : null
};
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);
readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { isCacheValid: true, lastUpdateTimestamp: null });
},
onReadyFromCacheCb() {
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED);
readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
}
});

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);
if ((storage as IStorageSync).splits.getChangeNumber() > -1) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED, { isCacheValid: true, lastUpdateTimestamp: null });
}

const clients: Record<string, SplitIO.IBasicClient> = {};
Expand Down
32 changes: 25 additions & 7 deletions src/storages/inLocalStorage/__tests__/validateCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ describe.each(storages)('validateCache', (storage) => {
});

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

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -45,11 +47,15 @@ describe.each(storages)('validateCache', (storage) => {
});

test('if there is cache and it must not be cleared, it should return true', 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();

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

expect(logSpy).not.toHaveBeenCalled();

Expand All @@ -69,7 +75,9 @@ describe.each(storages)('validateCache', (storage) => {
storage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
await storage.save && storage.save();

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

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

Expand All @@ -87,7 +95,9 @@ describe.each(storages)('validateCache', (storage) => {
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

expect(await validateCache({}, storage, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another-sdk-key' } }, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
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(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');

Expand All @@ -107,7 +117,9 @@ describe.each(storages)('validateCache', (storage) => {
storage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
await storage.save && storage.save();

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

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

Expand All @@ -122,14 +134,20 @@ describe.each(storages)('validateCache', (storage) => {

// If cache is cleared, it should not clear again until a day has passed
logSpy.mockClear();
const lastUpdateTimestamp = Date.now() - 1000 * 60 * 60; // 1 hour ago
storage.setItem(keys.buildSplitsTillKey(), '1');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(true);
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.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) + '');
expect(await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments)).toBe(false);
const result3 = await validateCache({ clearOnInit: true }, storage, fullSettings, keys, splits, rbSegments, segments, largeSegments);
expect(result3.isCacheValid).toBe(false);
expect(result3.lastUpdateTimestamp).toBeNull();
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
9 changes: 6 additions & 3 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 } from './validateCache';
import { validateCache, CacheValidationMetadata } 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<boolean> | undefined;
let validateCachePromise: Promise<CacheValidationMetadata> | undefined;

return {
splits,
Expand All @@ -68,7 +68,10 @@ export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): ISt
uniqueKeys: new UniqueKeysCacheInMemoryCS(),

validateCache() {
return validateCachePromise || (validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments));
if (!validateCachePromise) {
validateCachePromise = validateCache(options, storage, settings, keys, splits, rbSegments, segments, largeSegments);
}
return validateCachePromise;
},

save() {
Expand Down
Loading