From a719bb7e06d67afdfc918f332591519db4eec83b Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Mon, 5 Jan 2026 20:23:02 -0300 Subject: [PATCH 1/7] [FME-12059] SDK_UPDATE with metadata --- .../__tests__/readinessManager.spec.ts | 60 ++++++++ src/readiness/readinessManager.ts | 7 +- .../__tests__/TelemetryCacheInRedis.spec.ts | 128 ++++++++++-------- src/sync/polling/types.ts | 10 ++ .../__tests__/segmentChangesUpdater.spec.ts | 52 +++++++ .../__tests__/splitChangesUpdater.spec.ts | 30 +++- .../polling/updaters/segmentChangesUpdater.ts | 2 +- .../polling/updaters/splitChangesUpdater.ts | 13 +- types/splitio.d.ts | 4 + 9 files changed, 237 insertions(+), 69 deletions(-) create mode 100644 src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 34eaf9a3..69fe4008 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -3,6 +3,7 @@ import { EventEmitter } from '../../utils/MinEvents'; import { IReadinessManager } from '../types'; import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants'; import { ISettings } from '../../types'; +import { EventMetadata, SdkUpdateMetadataKeys } from '../../sync/polling/types'; const settings = { startup: { @@ -300,3 +301,62 @@ test('READINESS MANAGER / Destroy before it was ready and timedout', (done) => { }, settingsWithTimeout.startup.readyTimeout * 1.5); }); + +test('READINESS MANAGER / SDK_UPDATE should emit with metadata', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + const metadata: EventMetadata = { + [SdkUpdateMetadataKeys.UPDATED_FLAGS]: ['flag1', 'flag2'] + }; + + let receivedMetadata: EventMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.splits.emit(SDK_SPLITS_ARRIVED, metadata); + + expect(receivedMetadata).toEqual(metadata); +}); + +test('READINESS MANAGER / SDK_UPDATE should handle undefined metadata', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + let receivedMetadata: any; + readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + + expect(receivedMetadata).toBeUndefined(); +}); + +test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + // SDK_READY + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); + + const metadata: EventMetadata = { + [SdkUpdateMetadataKeys.UPDATED_SEGMENTS]: ['segment1', 'segment2'] + }; + + let receivedMetadata: EventMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + receivedMetadata = meta; + }); + + readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); + + expect(receivedMetadata).toEqual(metadata); +}); diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index 319e843d..48db9a40 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -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 { SdkUpdateMetadata } from '../sync/polling/types'; function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter { const splitsEventEmitter = objectAssign(new EventEmitter(), { @@ -15,7 +16,7 @@ function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter // `isSplitKill` condition avoids an edge-case of wrongly emitting SDK_READY if: // - `/memberships` fetch and SPLIT_KILL occurs before `/splitChanges` fetch, and // - storage has cached splits (for which case `splitsStorage.killLocally` can return true) - splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); + splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (metadata: SdkUpdateMetadata, isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); splitsEventEmitter.once(SDK_SPLITS_CACHE_LOADED, () => { splitsEventEmitter.splitsCacheLoaded = true; }); return splitsEventEmitter; @@ -98,12 +99,12 @@ export function readinessManagerFactory( } } - function checkIsReadyOrUpdate(diff: any) { + function checkIsReadyOrUpdate(metadata: SdkUpdateMetadata) { if (isDestroyed) return; if (isReady) { try { syncLastUpdate(); - gate.emit(SDK_UPDATE, diff); + gate.emit(SDK_UPDATE, metadata); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); diff --git a/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts index fb80ffce..f446e7d2 100644 --- a/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts +++ b/src/storages/inRedis/__tests__/TelemetryCacheInRedis.spec.ts @@ -10,78 +10,88 @@ const latencyKey = `${prefix}.telemetry.latencies`; const initKey = `${prefix}.telemetry.init`; const fieldVersionablePrefix = `${metadata.s}/${metadata.n}/${metadata.i}`; -test('TELEMETRY CACHE IN REDIS', async () => { +describe('TELEMETRY CACHE IN REDIS', () => { + let connection: RedisAdapter; + let cache: TelemetryCacheInRedis; + let keysBuilder: KeyBuilderSS; - const keysBuilder = new KeyBuilderSS(prefix, metadata); - const connection = new RedisAdapter(loggerMock); - const cache = new TelemetryCacheInRedis(loggerMock, keysBuilder, connection); + beforeEach(async () => { + keysBuilder = new KeyBuilderSS(prefix, metadata); + connection = new RedisAdapter(loggerMock); + cache = new TelemetryCacheInRedis(loggerMock, keysBuilder, connection); - // recordException - expect(await cache.recordException('tr')).toBe(1); - expect(await cache.recordException('tr')).toBe(2); - expect(await cache.recordException('tcfs')).toBe(1); + await connection.del(exceptionKey); + await connection.del(latencyKey); + await connection.del(initKey); + }); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe('2'); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatment')).toBe(null); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatmentsWithConfigByFlagSets')).toBe('1'); + test('TELEMETRY CACHE IN REDIS', async () => { - // recordLatency - expect(await cache.recordLatency('tr', 1.6)).toBe(1); - expect(await cache.recordLatency('tr', 1.6)).toBe(2); - expect(await cache.recordLatency('tfs', 1.6)).toBe(1); + // recordException + expect(await cache.recordException('tr')).toBe(1); + expect(await cache.recordException('tr')).toBe(2); + expect(await cache.recordException('tcfs')).toBe(1); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe('2'); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatment/2')).toBe(null); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatmentsByFlagSets/2')).toBe('1'); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe('2'); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatment')).toBe(null); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/treatmentsWithConfigByFlagSets')).toBe('1'); - // recordConfig - expect(await cache.recordConfig()).toBe(1); - expect(JSON.parse(await connection.hget(initKey, fieldVersionablePrefix) as string)).toEqual({ - oM: 1, - st: 'redis', - aF: 0, - rF: 0 - }); + // recordLatency + expect(await cache.recordLatency('tr', 1.6)).toBe(1); + expect(await cache.recordLatency('tr', 1.6)).toBe(2); + expect(await cache.recordLatency('tfs', 1.6)).toBe(1); - // popLatencies - const latencies = await cache.popLatencies(); - latencies.forEach((latency, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(latency).toEqual({ - tfs: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - tr: [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }); - }); - expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe(null); - - // popExceptions - const exceptions = await cache.popExceptions(); - exceptions.forEach((exception, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(exception).toEqual({ - tcfs: 1, - tr: 2, - }); - }); - expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe(null); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe('2'); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatment/2')).toBe(null); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/treatmentsByFlagSets/2')).toBe('1'); - // popConfig - const configs = await cache.popConfigs(); - configs.forEach((config, m) => { - expect(JSON.parse(m)).toEqual(metadata); - expect(config).toEqual({ + // recordConfig + expect(await cache.recordConfig()).toBe(1); + expect(JSON.parse(await connection.hget(initKey, fieldVersionablePrefix) as string)).toEqual({ oM: 1, st: 'redis', aF: 0, rF: 0 }); - }); - expect(await connection.hget(initKey, fieldVersionablePrefix)).toBe(null); - // pops when there is no data - expect((await cache.popLatencies()).size).toBe(0); - expect((await cache.popExceptions()).size).toBe(0); - expect((await cache.popConfigs()).size).toBe(0); + // popLatencies + const latencies = await cache.popLatencies(); + latencies.forEach((latency, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(latency).toEqual({ + tfs: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + tr: [0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }); + }); + expect(await connection.hget(latencyKey, fieldVersionablePrefix + '/track/2')).toBe(null); + + // popExceptions + const exceptions = await cache.popExceptions(); + exceptions.forEach((exception, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(exception).toEqual({ + tcfs: 1, + tr: 2, + }); + }); + expect(await connection.hget(exceptionKey, fieldVersionablePrefix + '/track')).toBe(null); - await connection.disconnect(); + // popConfig + const configs = await cache.popConfigs(); + configs.forEach((config, m) => { + expect(JSON.parse(m)).toEqual(metadata); + expect(config).toEqual({ + oM: 1, + st: 'redis', + aF: 0, + rF: 0 + }); + }); + expect(await connection.hget(initKey, fieldVersionablePrefix)).toBe(null); + + // pops when there is no data + expect((await cache.popLatencies()).size).toBe(0); + expect((await cache.popExceptions()).size).toBe(0); + expect((await cache.popConfigs()).size).toBe(0); + }); }); diff --git a/src/sync/polling/types.ts b/src/sync/polling/types.ts index 4ff29c83..bb7b6c3e 100644 --- a/src/sync/polling/types.ts +++ b/src/sync/polling/types.ts @@ -31,3 +31,13 @@ export interface IPollingManagerCS extends IPollingManager { remove(matchingKey: string): void; get(matchingKey: string): IMySegmentsSyncTask | undefined } + +export enum SdkUpdateMetadataKeys { + UPDATED_FLAGS = 'updatedFlags', + UPDATED_SEGMENTS = 'updatedSegments' +} + +export type SdkUpdateMetadata = { + [SdkUpdateMetadataKeys.UPDATED_FLAGS]?: string[] + [SdkUpdateMetadataKeys.UPDATED_SEGMENTS]?: string[] +} diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts new file mode 100644 index 00000000..0a85f29a --- /dev/null +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -0,0 +1,52 @@ +import { readinessManagerFactory } from '../../../../readiness/readinessManager'; +import { SegmentsCacheInMemory } from '../../../../storages/inMemory/SegmentsCacheInMemory'; +import { segmentChangesUpdaterFactory } from '../segmentChangesUpdater'; +import { fullSettings } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../../../utils/MinEvents'; +import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; +import { ISegmentChangesFetcher } from '../../fetchers/types'; +import { ISegmentChangesResponse } from '../../../../dtos/types'; +import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; + +describe('segmentChangesUpdater', () => { + const segments = new SegmentsCacheInMemory(); + const updateSegments = jest.spyOn(segments, 'update'); + + const readinessManager = readinessManagerFactory(EventEmitter, fullSettings); + const segmentsEmitSpy = jest.spyOn(readinessManager.segments, 'emit'); + + beforeEach(() => { + jest.clearAllMocks(); + segments.clear(); + readinessManager.segments.segmentsArrived = false; + }); + + test('test with segments update - should emit updatedSegments and NOT updatedFlags', async () => { + const segmentName = 'test-segment'; + const segmentChange: ISegmentChangesResponse = { + name: segmentName, + added: ['key1', 'key2'], + removed: [], + since: -1, + till: 123 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([segmentChange]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segmentName]); + + await segmentChangesUpdater(undefined, segmentName); + + expect(updateSegments).toHaveBeenCalledWith(segmentName, segmentChange.added, segmentChange.removed, segmentChange.till); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { updatedSegments: [segmentName] }); + }); +}); diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index b93a7176..3bebacc7 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -15,6 +15,7 @@ import { splitNotifications } from '../../../streaming/__tests__/dataMocks'; import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegmentsCacheInMemory'; import { RB_SEGMENT_UPDATE, SPLIT_UPDATE } from '../../../streaming/constants'; import { IN_RULE_BASED_SEGMENT } from '../../../../utils/constants'; +import { SDK_SPLITS_ARRIVED } from '../../../../readiness/constants'; const ARCHIVED_FF = 'ARCHIVED'; @@ -120,6 +121,7 @@ test('splitChangesUpdater / compute splits mutation', () => { expect(splitsMutation.added).toEqual([activeSplitWithSegments]); expect(splitsMutation.removed).toEqual([archivedSplit]); + expect(splitsMutation.names).toEqual([activeSplitWithSegments.name, archivedSplit.name]); expect(Array.from(segments)).toEqual(['A', 'B']); // SDK initialization without sets @@ -129,6 +131,7 @@ test('splitChangesUpdater / compute splits mutation', () => { expect(splitsMutation.added).toEqual([testFFSetsAB, test2FFSetsX]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name, test2FFSetsX.name]); expect(Array.from(segments)).toEqual([]); }); @@ -142,24 +145,28 @@ test('splitChangesUpdater / compute splits mutation with filters', () => { // should add it to mutations expect(splitsMutation.added).toEqual([testFFSetsAB]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFRemoveSetB], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([testFFRemoveSetB]); expect(splitsMutation.removed).toEqual([]); + expect(splitsMutation.names).toEqual([testFFRemoveSetB.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFRemoveSetA], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFRemoveSetA]); + expect(splitsMutation.names).toEqual([testFFRemoveSetA.name]); // fetching existing test feature flag removed from set B splitsMutation = computeMutation([testFFEmptySet], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFEmptySet]); + expect(splitsMutation.names).toEqual([testFFEmptySet.name]); // SDK initialization with names: ['test2'] splitFiltersValidation = { queryString: '&names=test2', groupedFilters: { bySet: [], byName: ['test2'], byPrefix: [] }, validFilters: [] }; @@ -167,11 +174,13 @@ test('splitChangesUpdater / compute splits mutation with filters', () => { expect(splitsMutation.added).toEqual([]); expect(splitsMutation.removed).toEqual([testFFSetsAB]); + expect(splitsMutation.names).toEqual([testFFSetsAB.name]); splitsMutation = computeMutation([test2FFSetsX, testFFEmptySet], new Set(), splitFiltersValidation); expect(splitsMutation.added).toEqual([test2FFSetsX]); expect(splitsMutation.removed).toEqual([testFFEmptySet]); + expect(splitsMutation.names).toEqual([test2FFSetsX.name, testFFEmptySet.name]); }); describe('splitChangesUpdater', () => { @@ -204,6 +213,7 @@ describe('splitChangesUpdater', () => { test('test without payload', async () => { const result = await splitChangesUpdater(); + const updatedFlags = splitChangesMock1.ff.d.map(ff => ff.name); expect(fetchSplitChanges).toBeCalledTimes(1); expect(fetchSplitChanges).lastCalledWith(-1, undefined, undefined, -1); @@ -211,7 +221,7 @@ describe('splitChangesUpdater', () => { expect(updateSplits).lastCalledWith(splitChangesMock1.ff.d, [], splitChangesMock1.ff.t); expect(updateRbSegments).toBeCalledTimes(0); // no rbSegments to update expect(registerSegments).toBeCalledTimes(1); - expect(splitsEmitSpy).toBeCalledWith('state::splits-arrived'); + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { updatedFlags }); expect(result).toBe(true); }); @@ -276,7 +286,8 @@ describe('splitChangesUpdater', () => { // emit always if not configured sets for (const setMock of setMocks) { await expect(splitChangesUpdater(undefined, undefined, { payload: { ...payload, sets: setMock.sets, status: 'ACTIVE' }, changeNumber: index, type: SPLIT_UPDATE })).resolves.toBe(true); - expect(splitsEmitSpy.mock.calls[index][0]).toBe('state::splits-arrived'); + expect(splitsEmitSpy.mock.calls[index][0]).toBe(SDK_SPLITS_ARRIVED); + expect(splitsEmitSpy.mock.calls[index][1]).toEqual({ updatedFlags: [payload.name] }); index++; } @@ -294,4 +305,19 @@ describe('splitChangesUpdater', () => { } }); + + test('test with ff payload - should emit metadata with flag name', async () => { + splitsEmitSpy.mockClear(); + + readinessManager.splits.splitsArrived = false; + storage.splits.clear(); + + const payload = splitNotifications[0].decoded as Pick; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: SPLIT_UPDATE })).resolves.toBe(true); + + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { updatedFlags: [payload.name] }); + }); + }); diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index 7fe5b7b7..cf9fad2e 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -83,7 +83,7 @@ export function segmentChangesUpdaterFactory( // if at least one segment fetch succeeded, mark segments ready if (shouldUpdateFlags.some(update => update) || readyOnAlreadyExistentState) { readyOnAlreadyExistentState = false; - if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { updatedSegments: segmentNames }); } return true; }); diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 3a1fc5a7..3a4bf8df 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -54,7 +54,8 @@ export function parseSegments(ruleEntity: ISplit | IRBSegment, matcherType: type interface ISplitMutations { added: T[], - removed: T[] + removed: T[], + names: string[] } /** @@ -88,16 +89,18 @@ export function computeMutation(rules: Array, return rules.reduce((accum, ruleEntity) => { if (ruleEntity.status === 'ACTIVE' && (!filters || matchFilters(ruleEntity as ISplit, filters))) { accum.added.push(ruleEntity); + accum.names.push(ruleEntity.name); parseSegments(ruleEntity).forEach((segmentName: string) => { segments.add(segmentName); }); } else { accum.removed.push(ruleEntity); + accum.names.push(ruleEntity.name); } return accum; - }, { added: [], removed: [] } as ISplitMutations); + }, { added: [], removed: [], names: [] } as ISplitMutations); } /** @@ -165,9 +168,11 @@ export function splitChangesUpdaterFactory( .then((splitChanges: ISplitChangesResponse) => { const usedSegments = new Set(); + let updatedFlags: string[] = []; let ffUpdate: MaybeThenable = false; if (splitChanges.ff) { - const { added, removed } = computeMutation(splitChanges.ff.d, usedSegments, splitFiltersValidation); + const { added, removed, names } = computeMutation(splitChanges.ff.d, usedSegments, splitFiltersValidation); + updatedFlags = names; log.debug(SYNC_SPLITS_UPDATE, [added.length, removed.length]); ffUpdate = splits.update(added, removed, splitChanges.ff.t); } @@ -193,7 +198,7 @@ export function splitChangesUpdaterFactory( .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { // emit SDK events - if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED); + if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, { updatedFlags }); return true; }); } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index 1a505686..df3decb3 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -3,6 +3,8 @@ import { RedisOptions } from 'ioredis'; import { RequestOptions } from 'http'; +import { SDK_UPDATE } from '../src/readiness/types'; +import { SdkUpdateMetadata } from '../src/sync/polling/types'; export as namespace SplitIO; export = SplitIO; @@ -497,6 +499,7 @@ declare namespace SplitIO { */ interface IEventEmitter { addListener(event: string, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string, listener: (...args: any[]) => void): this; once(event: string, listener: (...args: any[]) => void): this; removeListener(event: string, listener: (...args: any[]) => void): this; @@ -510,6 +513,7 @@ declare namespace SplitIO { */ interface EventEmitter extends IEventEmitter { addListener(event: string | symbol, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string | symbol, listener: (...args: any[]) => void): this; once(event: string | symbol, listener: (...args: any[]) => void): this; removeListener(event: string | symbol, listener: (...args: any[]) => void): this; From ef651cc1ba3a647b2b8c569d151542c6959c4673 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Tue, 6 Jan 2026 15:48:36 -0300 Subject: [PATCH 2/7] Fix types --- .../__tests__/readinessManager.spec.ts | 16 +++++----- src/readiness/types.ts | 29 ++++++++++--------- src/sync/streaming/types.ts | 5 ++-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 69fe4008..7acecd03 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -3,7 +3,7 @@ import { EventEmitter } from '../../utils/MinEvents'; import { IReadinessManager } from '../types'; import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants'; import { ISettings } from '../../types'; -import { EventMetadata, SdkUpdateMetadataKeys } from '../../sync/polling/types'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../sync/polling/types'; const settings = { startup: { @@ -309,12 +309,12 @@ test('READINESS MANAGER / SDK_UPDATE should emit with metadata', () => { readinessManager.splits.emit(SDK_SPLITS_ARRIVED); readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); - const metadata: EventMetadata = { + const metadata: SdkUpdateMetadata = { [SdkUpdateMetadataKeys.UPDATED_FLAGS]: ['flag1', 'flag2'] }; - let receivedMetadata: EventMetadata | undefined; - readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + let receivedMetadata: SdkUpdateMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { receivedMetadata = meta; }); @@ -331,7 +331,7 @@ test('READINESS MANAGER / SDK_UPDATE should handle undefined metadata', () => { readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); let receivedMetadata: any; - readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { receivedMetadata = meta; }); @@ -347,12 +347,12 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () readinessManager.splits.emit(SDK_SPLITS_ARRIVED); readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); - const metadata: EventMetadata = { + const metadata: SdkUpdateMetadata = { [SdkUpdateMetadataKeys.UPDATED_SEGMENTS]: ['segment1', 'segment2'] }; - let receivedMetadata: EventMetadata | undefined; - readinessManager.gate.on(SDK_UPDATE, (meta: EventMetadata) => { + let receivedMetadata: SdkUpdateMetadata | undefined; + readinessManager.gate.on(SDK_UPDATE, (meta: SdkUpdateMetadata) => { receivedMetadata = meta; }); diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 2de99b43..cca51151 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,5 +1,19 @@ import SplitIO from '../../types/splitio'; +import { SdkUpdateMetadata } from '../sync/polling/types'; +/** Readiness event types */ + +export type SDK_READY_TIMED_OUT = 'init::timeout' +export type SDK_READY = 'init::ready' +export type SDK_READY_FROM_CACHE = 'init::cache-ready' +export type SDK_UPDATE = 'state::update' +export type SDK_DESTROY = 'state::destroy' + +export type IReadinessEvent = SDK_READY_TIMED_OUT | SDK_READY | SDK_READY_FROM_CACHE | SDK_UPDATE | SDK_DESTROY + +export interface IReadinessEventEmitter extends SplitIO.IEventEmitter { + emit(event: IReadinessEvent, ...args: any[]): boolean +} /** Splits data emitter */ type SDK_SPLITS_ARRIVED = 'state::splits-arrived' @@ -9,6 +23,7 @@ type ISplitsEvent = SDK_SPLITS_ARRIVED | SDK_SPLITS_CACHE_LOADED export interface ISplitsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISplitsEvent, ...args: any[]): boolean on(event: ISplitsEvent, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; once(event: ISplitsEvent, listener: (...args: any[]) => void): this; splitsArrived: boolean splitsCacheLoaded: boolean @@ -24,23 +39,11 @@ type ISegmentsEvent = SDK_SEGMENTS_ARRIVED export interface ISegmentsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISegmentsEvent, ...args: any[]): boolean on(event: ISegmentsEvent, listener: (...args: any[]) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; once(event: ISegmentsEvent, listener: (...args: any[]) => void): this; segmentsArrived: boolean } -/** Readiness emitter */ - -export type SDK_READY_TIMED_OUT = 'init::timeout' -export type SDK_READY = 'init::ready' -export type SDK_READY_FROM_CACHE = 'init::cache-ready' -export type SDK_UPDATE = 'state::update' -export type SDK_DESTROY = 'state::destroy' -export type IReadinessEvent = SDK_READY_TIMED_OUT | SDK_READY | SDK_READY_FROM_CACHE | SDK_UPDATE | SDK_DESTROY - -export interface IReadinessEventEmitter extends SplitIO.IEventEmitter { - emit(event: IReadinessEvent, ...args: any[]): boolean -} - /** Readiness manager */ export interface IReadinessManager { diff --git a/src/sync/streaming/types.ts b/src/sync/streaming/types.ts index fcf5048e..982f40e9 100644 --- a/src/sync/streaming/types.ts +++ b/src/sync/streaming/types.ts @@ -1,8 +1,9 @@ import { IMembershipMSUpdateData, IMembershipLSUpdateData, ISegmentUpdateData, ISplitUpdateData, ISplitKillData, INotificationData } from './SSEHandler/types'; import { ITask } from '../types'; -import { IMySegmentsSyncTask } from '../polling/types'; +import { IMySegmentsSyncTask, SdkUpdateMetadata } from '../polling/types'; import SplitIO from '../../../types/splitio'; import { ControlType } from './constants'; +import { SDK_UPDATE } from '../../readiness/types'; // Internal SDK events, subscribed by SyncManager and PushManager export type PUSH_SUBSYSTEM_UP = 'PUSH_SUBSYSTEM_UP' @@ -37,7 +38,7 @@ type IParsedData = */ export interface IPushEventEmitter extends SplitIO.IEventEmitter { once(event: T, listener: (parsedData: IParsedData) => void): this; - on(event: T, listener: (parsedData: IParsedData) => void): this; + on(event: T, listener: (metadata: T extends SDK_UPDATE ? SdkUpdateMetadata : never) => void): this; emit(event: T, parsedData?: IParsedData): boolean; } From f4a698888376d37f9633432896500c45e96be4a4 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Mon, 12 Jan 2026 10:39:53 -0300 Subject: [PATCH 3/7] Update metadata properties --- .../__tests__/readinessManager.spec.ts | 6 +++-- src/readiness/readinessManager.ts | 5 ++-- src/readiness/types.ts | 5 ++-- src/sync/polling/types.ts | 12 ++++++---- .../__tests__/segmentChangesUpdater.spec.ts | 3 ++- .../__tests__/splitChangesUpdater.spec.ts | 7 +++--- .../polling/updaters/segmentChangesUpdater.ts | 8 ++++++- .../polling/updaters/splitChangesUpdater.ts | 10 +++++--- src/sync/streaming/types.ts | 5 ++-- types/splitio.d.ts | 24 +++++++++++++++---- 10 files changed, 57 insertions(+), 28 deletions(-) diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 7acecd03..afaaaa96 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -310,7 +310,8 @@ test('READINESS MANAGER / SDK_UPDATE should emit with metadata', () => { readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); const metadata: SdkUpdateMetadata = { - [SdkUpdateMetadataKeys.UPDATED_FLAGS]: ['flag1', 'flag2'] + type: SdkUpdateMetadataKeys.FLAGS_UPDATE, + names: ['flag1', 'flag2'] }; let receivedMetadata: SdkUpdateMetadata | undefined; @@ -348,7 +349,8 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () readinessManager.segments.emit(SDK_SEGMENTS_ARRIVED); const metadata: SdkUpdateMetadata = { - [SdkUpdateMetadataKeys.UPDATED_SEGMENTS]: ['segment1', 'segment2'] + type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: ['segment1', 'segment2'] }; let receivedMetadata: SdkUpdateMetadata | undefined; diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index 48db9a40..591f8b02 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -3,7 +3,6 @@ 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 { SdkUpdateMetadata } from '../sync/polling/types'; function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter): ISplitsEventEmitter { const splitsEventEmitter = objectAssign(new EventEmitter(), { @@ -16,7 +15,7 @@ function splitsEventEmitterFactory(EventEmitter: new () => SplitIO.IEventEmitter // `isSplitKill` condition avoids an edge-case of wrongly emitting SDK_READY if: // - `/memberships` fetch and SPLIT_KILL occurs before `/splitChanges` fetch, and // - storage has cached splits (for which case `splitsStorage.killLocally` can return true) - splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (metadata: SdkUpdateMetadata, isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); + splitsEventEmitter.on(SDK_SPLITS_ARRIVED, (metadata: SplitIO.SdkUpdateMetadata, isSplitKill: boolean) => { if (!isSplitKill) splitsEventEmitter.splitsArrived = true; }); splitsEventEmitter.once(SDK_SPLITS_CACHE_LOADED, () => { splitsEventEmitter.splitsCacheLoaded = true; }); return splitsEventEmitter; @@ -99,7 +98,7 @@ export function readinessManagerFactory( } } - function checkIsReadyOrUpdate(metadata: SdkUpdateMetadata) { + function checkIsReadyOrUpdate(metadata: SplitIO.SdkUpdateMetadata) { if (isDestroyed) return; if (isReady) { try { diff --git a/src/readiness/types.ts b/src/readiness/types.ts index cca51151..3f726d64 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -1,5 +1,4 @@ import SplitIO from '../../types/splitio'; -import { SdkUpdateMetadata } from '../sync/polling/types'; /** Readiness event types */ @@ -23,7 +22,7 @@ type ISplitsEvent = SDK_SPLITS_ARRIVED | SDK_SPLITS_CACHE_LOADED export interface ISplitsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISplitsEvent, ...args: any[]): boolean on(event: ISplitsEvent, listener: (...args: any[]) => void): this; - on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; once(event: ISplitsEvent, listener: (...args: any[]) => void): this; splitsArrived: boolean splitsCacheLoaded: boolean @@ -39,7 +38,7 @@ type ISegmentsEvent = SDK_SEGMENTS_ARRIVED export interface ISegmentsEventEmitter extends SplitIO.IEventEmitter { emit(event: ISegmentsEvent, ...args: any[]): boolean on(event: ISegmentsEvent, listener: (...args: any[]) => void): this; - on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; once(event: ISegmentsEvent, listener: (...args: any[]) => void): this; segmentsArrived: boolean } diff --git a/src/sync/polling/types.ts b/src/sync/polling/types.ts index bb7b6c3e..3b91476c 100644 --- a/src/sync/polling/types.ts +++ b/src/sync/polling/types.ts @@ -33,11 +33,13 @@ export interface IPollingManagerCS extends IPollingManager { } export enum SdkUpdateMetadataKeys { - UPDATED_FLAGS = 'updatedFlags', - UPDATED_SEGMENTS = 'updatedSegments' + FLAGS_UPDATE = 'FLAGS_UPDATE', + SEGMENTS_UPDATE = 'SEGMENTS_UPDATE' } - +/** + * SdkUpdateMetadata type for polling updaters + */ export type SdkUpdateMetadata = { - [SdkUpdateMetadataKeys.UPDATED_FLAGS]?: string[] - [SdkUpdateMetadataKeys.UPDATED_SEGMENTS]?: string[] + type: SdkUpdateMetadataKeys.FLAGS_UPDATE | SdkUpdateMetadataKeys.SEGMENTS_UPDATE + names: string[] } diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts index 0a85f29a..d3a2bedf 100644 --- a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -7,6 +7,7 @@ import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; import { ISegmentChangesFetcher } from '../../fetchers/types'; import { ISegmentChangesResponse } from '../../../../dtos/types'; import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../types'; describe('segmentChangesUpdater', () => { const segments = new SegmentsCacheInMemory(); @@ -47,6 +48,6 @@ describe('segmentChangesUpdater', () => { await segmentChangesUpdater(undefined, segmentName); expect(updateSegments).toHaveBeenCalledWith(segmentName, segmentChange.added, segmentChange.removed, segmentChange.till); - expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { updatedSegments: [segmentName] }); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [segmentName] }); }); }); diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index 3bebacc7..6b59f6fb 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -16,6 +16,7 @@ import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegment import { RB_SEGMENT_UPDATE, SPLIT_UPDATE } from '../../../streaming/constants'; import { IN_RULE_BASED_SEGMENT } from '../../../../utils/constants'; import { SDK_SPLITS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../types'; const ARCHIVED_FF = 'ARCHIVED'; @@ -221,7 +222,7 @@ describe('splitChangesUpdater', () => { expect(updateSplits).lastCalledWith(splitChangesMock1.ff.d, [], splitChangesMock1.ff.t); expect(updateRbSegments).toBeCalledTimes(0); // no rbSegments to update expect(registerSegments).toBeCalledTimes(1); - expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { updatedFlags }); + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: updatedFlags }); expect(result).toBe(true); }); @@ -287,7 +288,7 @@ describe('splitChangesUpdater', () => { for (const setMock of setMocks) { await expect(splitChangesUpdater(undefined, undefined, { payload: { ...payload, sets: setMock.sets, status: 'ACTIVE' }, changeNumber: index, type: SPLIT_UPDATE })).resolves.toBe(true); expect(splitsEmitSpy.mock.calls[index][0]).toBe(SDK_SPLITS_ARRIVED); - expect(splitsEmitSpy.mock.calls[index][1]).toEqual({ updatedFlags: [payload.name] }); + expect(splitsEmitSpy.mock.calls[index][1]).toEqual({ type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); index++; } @@ -317,7 +318,7 @@ describe('splitChangesUpdater', () => { await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: SPLIT_UPDATE })).resolves.toBe(true); - expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { updatedFlags: [payload.name] }); + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); }); }); diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index cf9fad2e..67179c69 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -5,6 +5,8 @@ import { SDK_SEGMENTS_ARRIVED } from '../../../readiness/constants'; import { ILogger } from '../../../logger/types'; import { LOG_PREFIX_INSTANTIATION, LOG_PREFIX_SYNC_SEGMENTS } from '../../../logger/constants'; import { timeout } from '../../../utils/promise/timeout'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../types'; + type ISegmentChangesUpdater = (fetchOnlyNew?: boolean, segmentName?: string, noCache?: boolean, till?: number) => Promise @@ -83,7 +85,11 @@ export function segmentChangesUpdaterFactory( // if at least one segment fetch succeeded, mark segments ready if (shouldUpdateFlags.some(update => update) || readyOnAlreadyExistentState) { readyOnAlreadyExistentState = false; - if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { updatedSegments: segmentNames }); + const metadata: SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: segmentNames + }; + if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); } return true; }); diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 3a4bf8df..98b3bf37 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -10,6 +10,7 @@ import { startsWith } from '../../../utils/lang'; import { IN_RULE_BASED_SEGMENT, IN_SEGMENT, RULE_BASED_SEGMENT, STANDARD_SEGMENT } from '../../../utils/constants'; import { setToArray } from '../../../utils/lang/sets'; import { SPLIT_UPDATE } from '../../streaming/constants'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../types'; export type InstantUpdate = { payload: ISplit | IRBSegment, changeNumber: number, type: string }; type SplitChangesUpdater = (noCache?: boolean, till?: number, instantUpdate?: InstantUpdate) => Promise @@ -89,15 +90,14 @@ export function computeMutation(rules: Array, return rules.reduce((accum, ruleEntity) => { if (ruleEntity.status === 'ACTIVE' && (!filters || matchFilters(ruleEntity as ISplit, filters))) { accum.added.push(ruleEntity); - accum.names.push(ruleEntity.name); parseSegments(ruleEntity).forEach((segmentName: string) => { segments.add(segmentName); }); } else { accum.removed.push(ruleEntity); - accum.names.push(ruleEntity.name); } + accum.names.push(ruleEntity.name); return accum; }, { added: [], removed: [], names: [] } as ISplitMutations); @@ -197,8 +197,12 @@ export function splitChangesUpdaterFactory( return Promise.resolve(!splitsEventEmitter.splitsArrived || ((ffChanged || rbsChanged) && (isClientSide || checkAllSegmentsExist(segments)))) .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { + const metadata: SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.FLAGS_UPDATE, + names: updatedFlags + }; // emit SDK events - if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, { updatedFlags }); + if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, metadata); return true; }); } diff --git a/src/sync/streaming/types.ts b/src/sync/streaming/types.ts index 982f40e9..fcf5048e 100644 --- a/src/sync/streaming/types.ts +++ b/src/sync/streaming/types.ts @@ -1,9 +1,8 @@ import { IMembershipMSUpdateData, IMembershipLSUpdateData, ISegmentUpdateData, ISplitUpdateData, ISplitKillData, INotificationData } from './SSEHandler/types'; import { ITask } from '../types'; -import { IMySegmentsSyncTask, SdkUpdateMetadata } from '../polling/types'; +import { IMySegmentsSyncTask } from '../polling/types'; import SplitIO from '../../../types/splitio'; import { ControlType } from './constants'; -import { SDK_UPDATE } from '../../readiness/types'; // Internal SDK events, subscribed by SyncManager and PushManager export type PUSH_SUBSYSTEM_UP = 'PUSH_SUBSYSTEM_UP' @@ -38,7 +37,7 @@ type IParsedData = */ export interface IPushEventEmitter extends SplitIO.IEventEmitter { once(event: T, listener: (parsedData: IParsedData) => void): this; - on(event: T, listener: (metadata: T extends SDK_UPDATE ? SdkUpdateMetadata : never) => void): this; + on(event: T, listener: (parsedData: IParsedData) => void): this; emit(event: T, parsedData?: IParsedData): boolean; } diff --git a/types/splitio.d.ts b/types/splitio.d.ts index df3decb3..e23ae462 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -3,8 +3,6 @@ import { RedisOptions } from 'ioredis'; import { RequestOptions } from 'http'; -import { SDK_UPDATE } from '../src/readiness/types'; -import { SdkUpdateMetadata } from '../src/sync/polling/types'; export as namespace SplitIO; export = SplitIO; @@ -494,12 +492,28 @@ declare namespace SplitIO { removeItem(key: string): void | Promise; } + /** + * Metadata keys for SDK update events. + */ + enum SdkUpdateMetadataKeys { + FLAGS_UPDATE = 'FLAGS_UPDATE', + SEGMENTS_UPDATE = 'SEGMENTS_UPDATE' + } + + /** + * List of modified flags or segments + * when a sdk update event is emitted. + */ + type SdkUpdateMetadata = { + type: SdkUpdateMetadataKeys.FLAGS_UPDATE | SdkUpdateMetadataKeys.SEGMENTS_UPDATE + names: string[] + } + /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ interface IEventEmitter { addListener(event: string, listener: (...args: any[]) => void): this; - on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string, listener: (...args: any[]) => void): this; once(event: string, listener: (...args: any[]) => void): this; removeListener(event: string, listener: (...args: any[]) => void): this; @@ -512,9 +526,11 @@ declare namespace SplitIO { * @see {@link https://nodejs.org/api/events.html} */ interface EventEmitter extends IEventEmitter { + addListener(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; addListener(event: string | symbol, listener: (...args: any[]) => void): this; - on(event: SDK_UPDATE, listener: (metadata: SdkUpdateMetadata) => void): this; + on(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; once(event: string | symbol, listener: (...args: any[]) => void): this; removeListener(event: string | symbol, listener: (...args: any[]) => void): this; off(event: string | symbol, listener: (...args: any[]) => void): this; From 631366d178f4f23783b513f4e02a02d7b701ac2d Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Mon, 12 Jan 2026 11:01:15 -0300 Subject: [PATCH 4/7] Move enum and type to namespace --- .../__tests__/readinessManager.spec.ts | 2 +- src/sync/polling/types.ts | 12 ------------ .../__tests__/segmentChangesUpdater.spec.ts | 2 +- .../__tests__/splitChangesUpdater.spec.ts | 2 +- .../polling/updaters/segmentChangesUpdater.ts | 2 +- .../polling/updaters/splitChangesUpdater.ts | 2 +- types/splitio.d.ts | 17 ++++++++++++++--- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index afaaaa96..40fe34a0 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -3,7 +3,7 @@ import { EventEmitter } from '../../utils/MinEvents'; import { IReadinessManager } from '../types'; import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants'; import { ISettings } from '../../types'; -import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../sync/polling/types'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../../types/splitio'; const settings = { startup: { diff --git a/src/sync/polling/types.ts b/src/sync/polling/types.ts index 3b91476c..4ff29c83 100644 --- a/src/sync/polling/types.ts +++ b/src/sync/polling/types.ts @@ -31,15 +31,3 @@ export interface IPollingManagerCS extends IPollingManager { remove(matchingKey: string): void; get(matchingKey: string): IMySegmentsSyncTask | undefined } - -export enum SdkUpdateMetadataKeys { - FLAGS_UPDATE = 'FLAGS_UPDATE', - SEGMENTS_UPDATE = 'SEGMENTS_UPDATE' -} -/** - * SdkUpdateMetadata type for polling updaters - */ -export type SdkUpdateMetadata = { - type: SdkUpdateMetadataKeys.FLAGS_UPDATE | SdkUpdateMetadataKeys.SEGMENTS_UPDATE - names: string[] -} diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts index d3a2bedf..81113692 100644 --- a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -7,7 +7,7 @@ import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; import { ISegmentChangesFetcher } from '../../fetchers/types'; import { ISegmentChangesResponse } from '../../../../dtos/types'; import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; -import { SdkUpdateMetadataKeys } from '../../types'; +import { SdkUpdateMetadataKeys } from '../../../../../types/splitio'; describe('segmentChangesUpdater', () => { const segments = new SegmentsCacheInMemory(); diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index 6b59f6fb..3ea8c740 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -16,7 +16,7 @@ import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegment import { RB_SEGMENT_UPDATE, SPLIT_UPDATE } from '../../../streaming/constants'; import { IN_RULE_BASED_SEGMENT } from '../../../../utils/constants'; import { SDK_SPLITS_ARRIVED } from '../../../../readiness/constants'; -import { SdkUpdateMetadataKeys } from '../../types'; +import { SdkUpdateMetadataKeys } from '../../../../../types/splitio'; const ARCHIVED_FF = 'ARCHIVED'; diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index 67179c69..e29dc551 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -5,7 +5,7 @@ import { SDK_SEGMENTS_ARRIVED } from '../../../readiness/constants'; import { ILogger } from '../../../logger/types'; import { LOG_PREFIX_INSTANTIATION, LOG_PREFIX_SYNC_SEGMENTS } from '../../../logger/constants'; import { timeout } from '../../../utils/promise/timeout'; -import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../types'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../../../types/splitio'; type ISegmentChangesUpdater = (fetchOnlyNew?: boolean, segmentName?: string, noCache?: boolean, till?: number) => Promise diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 98b3bf37..5e3bf986 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -10,7 +10,7 @@ import { startsWith } from '../../../utils/lang'; import { IN_RULE_BASED_SEGMENT, IN_SEGMENT, RULE_BASED_SEGMENT, STANDARD_SEGMENT } from '../../../utils/constants'; import { setToArray } from '../../../utils/lang/sets'; import { SPLIT_UPDATE } from '../../streaming/constants'; -import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../types'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../../../types/splitio'; export type InstantUpdate = { payload: ISplit | IRBSegment, changeNumber: number, type: string }; type SplitChangesUpdater = (noCache?: boolean, till?: number, instantUpdate?: InstantUpdate) => Promise diff --git a/types/splitio.d.ts b/types/splitio.d.ts index e23ae462..e908bc13 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -495,17 +495,28 @@ declare namespace SplitIO { /** * Metadata keys for SDK update events. */ - enum SdkUpdateMetadataKeys { + const enum SdkUpdateMetadataKeys { + /** + * The update event emitted when the SDK cache is updated with new data for flags. + */ FLAGS_UPDATE = 'FLAGS_UPDATE', + /** + * The update event emitted when the SDK cache is updated with new data for segments. + */ SEGMENTS_UPDATE = 'SEGMENTS_UPDATE' } /** - * List of modified flags or segments - * when a sdk update event is emitted. + * Metadata for the update event emitted when the SDK cache is updated with new data for flags or segments. */ type SdkUpdateMetadata = { + /** + * The type of update event. + */ type: SdkUpdateMetadataKeys.FLAGS_UPDATE | SdkUpdateMetadataKeys.SEGMENTS_UPDATE + /** + * The names of the flags or segments that were updated. + */ names: string[] } From 6f054c99db10eca7991f3cac661b7dd947d988d7 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Mon, 12 Jan 2026 15:09:46 -0300 Subject: [PATCH 5/7] Remove names when segments update --- src/readiness/__tests__/readinessManager.spec.ts | 2 +- .../polling/updaters/__tests__/segmentChangesUpdater.spec.ts | 2 +- src/sync/polling/updaters/segmentChangesUpdater.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 40fe34a0..4af32c80 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -350,7 +350,7 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () const metadata: SdkUpdateMetadata = { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, - names: ['segment1', 'segment2'] + names: [] }; let receivedMetadata: SdkUpdateMetadata | undefined; diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts index 81113692..acef0f94 100644 --- a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -48,6 +48,6 @@ describe('segmentChangesUpdater', () => { await segmentChangesUpdater(undefined, segmentName); expect(updateSegments).toHaveBeenCalledWith(segmentName, segmentChange.added, segmentChange.removed, segmentChange.till); - expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [segmentName] }); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); }); }); diff --git a/src/sync/polling/updaters/segmentChangesUpdater.ts b/src/sync/polling/updaters/segmentChangesUpdater.ts index e29dc551..5bda5d9f 100644 --- a/src/sync/polling/updaters/segmentChangesUpdater.ts +++ b/src/sync/polling/updaters/segmentChangesUpdater.ts @@ -87,7 +87,7 @@ export function segmentChangesUpdaterFactory( readyOnAlreadyExistentState = false; const metadata: SdkUpdateMetadata = { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, - names: segmentNames + names: [] }; if (readiness) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); } From c9795eabfba4d1f8f2cabeb1d54150aa8b67f457 Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Tue, 13 Jan 2026 17:33:56 -0300 Subject: [PATCH 6/7] avoid metadata when rbs update --- src/sync/polling/updaters/splitChangesUpdater.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 5e3bf986..5f3511a1 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -198,8 +198,8 @@ export function splitChangesUpdaterFactory( .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { const metadata: SdkUpdateMetadata = { - type: SdkUpdateMetadataKeys.FLAGS_UPDATE, - names: updatedFlags + type: ffChanged ? SdkUpdateMetadataKeys.FLAGS_UPDATE : SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: ffChanged ? updatedFlags : [] }; // emit SDK events if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, metadata); From 27dc057d82bef54120d7d920e906af6b9d5a7dad Mon Sep 17 00:00:00 2001 From: Emmanuel Zamora Date: Sat, 17 Jan 2026 16:43:26 -0300 Subject: [PATCH 7/7] Add ready metadata and polling --- .../__tests__/readinessManager.spec.ts | 80 ++++++++++++- src/readiness/readinessManager.ts | 19 ++- src/readiness/types.ts | 12 ++ .../offline/syncTasks/fromObjectSyncTask.ts | 5 +- src/sync/polling/pollingManagerCS.ts | 5 +- .../__tests__/mySegmentsUpdater.spec.ts | 108 ++++++++++++++++++ .../__tests__/segmentChangesUpdater.spec.ts | 82 +++++++++++++ .../__tests__/splitChangesUpdater.spec.ts | 104 +++++++++++++++++ .../polling/updaters/mySegmentsUpdater.ts | 3 +- .../polling/updaters/splitChangesUpdater.ts | 4 +- types/splitio.d.ts | 24 ++++ 11 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts diff --git a/src/readiness/__tests__/readinessManager.spec.ts b/src/readiness/__tests__/readinessManager.spec.ts index 4af32c80..c7cf1f0c 100644 --- a/src/readiness/__tests__/readinessManager.spec.ts +++ b/src/readiness/__tests__/readinessManager.spec.ts @@ -3,7 +3,7 @@ import { EventEmitter } from '../../utils/MinEvents'; import { IReadinessManager } from '../types'; import { SDK_READY, SDK_UPDATE, SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_READY_FROM_CACHE, SDK_SPLITS_CACHE_LOADED, SDK_READY_TIMED_OUT } from '../constants'; import { ISettings } from '../../types'; -import { SdkUpdateMetadata, SdkUpdateMetadataKeys } from '../../../types/splitio'; +import { SdkUpdateMetadata, SdkUpdateMetadataKeys, SdkReadyMetadata } from '../../../types/splitio'; const settings = { startup: { @@ -362,3 +362,81 @@ test('READINESS MANAGER / SDK_UPDATE should forward metadata from segments', () expect(receivedMetadata).toEqual(metadata); }); + +test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when cache is loaded', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + 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); + + 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); +}); + +test('READINESS MANAGER / SDK_READY_FROM_CACHE should emit with metadata when SDK becomes ready without cache', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY_FROM_CACHE, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready without cache first + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + 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); +}); + +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); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + 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); +}); + +test('READINESS MANAGER / SDK_READY should emit with metadata when ready without cache', () => { + const readinessManager = readinessManagerFactory(EventEmitter, settings); + + let receivedMetadata: SdkReadyMetadata | undefined; + readinessManager.gate.on(SDK_READY, (meta: SdkReadyMetadata) => { + receivedMetadata = meta; + }); + + // Make SDK ready without cache + readinessManager.splits.emit(SDK_SPLITS_ARRIVED); + 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); +}); diff --git a/src/readiness/readinessManager.ts b/src/readiness/readinessManager.ts index 591f8b02..68fefc28 100644 --- a/src/readiness/readinessManager.ts +++ b/src/readiness/readinessManager.ts @@ -90,7 +90,11 @@ export function readinessManagerFactory( if (!isReady && !isDestroyed) { try { syncLastUpdate(); - gate.emit(SDK_READY_FROM_CACHE, isReady); + const metadata: SplitIO.SdkReadyMetadata = { + initialCacheLoad: true, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY_FROM_CACHE, metadata); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); @@ -114,11 +118,20 @@ export function readinessManagerFactory( isReady = true; try { syncLastUpdate(); + const wasReadyFromCache = isReadyFromCache; if (!isReadyFromCache) { isReadyFromCache = true; - gate.emit(SDK_READY_FROM_CACHE, isReady); + const metadataFromCache: SplitIO.SdkReadyMetadata = { + initialCacheLoad: false, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY_FROM_CACHE, metadataFromCache); } - gate.emit(SDK_READY); + const metadataReady: SplitIO.SdkReadyMetadata = { + initialCacheLoad: wasReadyFromCache, + lastUpdateTimestamp: lastUpdate + }; + gate.emit(SDK_READY, metadataReady); } catch (e) { // throws user callback exceptions in next tick setTimeout(() => { throw e; }, 0); diff --git a/src/readiness/types.ts b/src/readiness/types.ts index 3f726d64..03ac2a0f 100644 --- a/src/readiness/types.ts +++ b/src/readiness/types.ts @@ -12,6 +12,18 @@ export type IReadinessEvent = SDK_READY_TIMED_OUT | SDK_READY | SDK_READY_FROM_C export interface IReadinessEventEmitter extends SplitIO.IEventEmitter { emit(event: IReadinessEvent, ...args: any[]): boolean + on(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + on(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + on(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + once(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + once(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + once(event: string | symbol, listener: (...args: any[]) => void): this; + addListener(event: SDK_READY, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + addListener(event: SDK_READY_FROM_CACHE, listener: (metadata: SplitIO.SdkReadyMetadata) => void): this; + addListener(event: SDK_UPDATE, listener: (metadata: SplitIO.SdkUpdateMetadata) => void): this; + addListener(event: string | symbol, listener: (...args: any[]) => void): this; } /** Splits data emitter */ diff --git a/src/sync/offline/syncTasks/fromObjectSyncTask.ts b/src/sync/offline/syncTasks/fromObjectSyncTask.ts index 96bc8384..5a439092 100644 --- a/src/sync/offline/syncTasks/fromObjectSyncTask.ts +++ b/src/sync/offline/syncTasks/fromObjectSyncTask.ts @@ -9,6 +9,7 @@ import { ISettings } from '../../../types'; import { CONTROL } from '../../../utils/constants'; import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED, SDK_SPLITS_CACHE_LOADED } from '../../../readiness/constants'; import { SYNC_OFFLINE_DATA, ERROR_SYNC_OFFLINE_LOADING } from '../../../logger/constants'; +import { SdkUpdateMetadataKeys } from '../../../../types/splitio'; /** * Offline equivalent of `splitChangesUpdaterFactory` @@ -55,7 +56,7 @@ export function fromObjectUpdaterFactory( splitsCache.clear(), // required to sync removed splits from mock splitsCache.update(splits, [], Date.now()) ]).then(() => { - readiness.splits.emit(SDK_SPLITS_ARRIVED); + readiness.splits.emit(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [] }); if (startingUp) { startingUp = false; @@ -63,7 +64,7 @@ export function fromObjectUpdaterFactory( // Emits SDK_READY_FROM_CACHE if (isCacheLoaded) readiness.splits.emit(SDK_SPLITS_CACHE_LOADED); // Emits SDK_READY - readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + readiness.segments.emit(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); }); } return true; diff --git a/src/sync/polling/pollingManagerCS.ts b/src/sync/polling/pollingManagerCS.ts index 5e197e62..5c1169d3 100644 --- a/src/sync/polling/pollingManagerCS.ts +++ b/src/sync/polling/pollingManagerCS.ts @@ -9,6 +9,7 @@ import { SDK_SPLITS_ARRIVED, SDK_SEGMENTS_ARRIVED } from '../../readiness/consta import { POLLING_SMART_PAUSING, POLLING_START, POLLING_STOP } from '../../logger/constants'; import { ISdkFactoryContextSync } from '../../sdkFactory/types'; import { usesSegmentsSync } from '../../storages/AbstractSplitsCacheSync'; +import { SdkUpdateMetadata } from '../../../types/splitio'; /** * Expose start / stop mechanism for polling data from services. @@ -59,8 +60,8 @@ export function pollingManagerCSFactory( const mySegmentsSyncTask = mySegmentsSyncTaskFactory(splitApi.fetchMemberships, storage, readiness, settings, matchingKey); // smart ready - function smartReady() { - if (!readiness.isReady() && !usesSegmentsSync(storage)) readiness.segments.emit(SDK_SEGMENTS_ARRIVED); + function smartReady(metadata: SdkUpdateMetadata) { + if (!readiness.isReady() && !usesSegmentsSync(storage)) readiness.segments.emit(SDK_SEGMENTS_ARRIVED, metadata); } if (!usesSegmentsSync(storage)) setTimeout(smartReady, 0); else readiness.splits.once(SDK_SPLITS_ARRIVED, smartReady); diff --git a/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts b/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts new file mode 100644 index 00000000..e197c991 --- /dev/null +++ b/src/sync/polling/updaters/__tests__/mySegmentsUpdater.spec.ts @@ -0,0 +1,108 @@ +import { readinessManagerFactory } from '../../../../readiness/readinessManager'; +import { MySegmentsCacheInMemory } from '../../../../storages/inMemory/MySegmentsCacheInMemory'; +import { mySegmentsUpdaterFactory } from '../mySegmentsUpdater'; +import { fullSettings } from '../../../../utils/settingsValidation/__tests__/settings.mocks'; +import { EventEmitter } from '../../../../utils/MinEvents'; +import { loggerMock } from '../../../../logger/__tests__/sdkLogger.mock'; +import { IMySegmentsFetcher } from '../../fetchers/types'; +import { IMembershipsResponse } from '../../../../dtos/types'; +import { SDK_SEGMENTS_ARRIVED } from '../../../../readiness/constants'; +import { SdkUpdateMetadataKeys } from '../../../../../types/splitio'; +import { MySegmentsData } from '../../types'; +import { MEMBERSHIPS_MS_UPDATE } from '../../../streaming/constants'; +import { IStorageSync } from '../../../../storages/types'; +import { SplitsCacheInMemory } from '../../../../storages/inMemory/SplitsCacheInMemory'; +import { RBSegmentsCacheInMemory } from '../../../../storages/inMemory/RBSegmentsCacheInMemory'; + +describe('mySegmentsUpdater', () => { + const segments = new MySegmentsCacheInMemory(); + const largeSegments = new MySegmentsCacheInMemory(); + const splits = new SplitsCacheInMemory(); + const rbSegments = new RBSegmentsCacheInMemory(); + const storage: IStorageSync = { + segments, + largeSegments, + splits, + rbSegments, + impressions: {} as any, + events: {} as any, + impressionCounts: {} as any, + telemetry: undefined, + uniqueKeys: {} as any, + save: () => {}, + destroy: () => {} + }; + const readinessManager = readinessManagerFactory(EventEmitter, fullSettings); + const segmentsEmitSpy = jest.spyOn(readinessManager.segments, 'emit'); + + beforeEach(() => { + jest.clearAllMocks(); + storage.segments.clear(); + readinessManager.segments.segmentsArrived = false; + }); + + test('test with mySegments update - should emit SEGMENTS_UPDATE metadata', async () => { + const mockMySegmentsFetcher: IMySegmentsFetcher = jest.fn().mockResolvedValue({ + ms: { 'segment1': true, 'segment2': true }, + ls: {} + } as IMembershipsResponse); + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + mockMySegmentsFetcher, + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with mySegments data payload - should emit SEGMENTS_UPDATE metadata', async () => { + const segmentsData: MySegmentsData = { + type: MEMBERSHIPS_MS_UPDATE, + cn: 123, + added: ['segment1', 'segment2'], + removed: [] + }; + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + jest.fn().mockResolvedValue({ ms: {}, ls: {} } as IMembershipsResponse), + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(segmentsData); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with empty mySegments - should still emit SEGMENTS_UPDATE metadata', async () => { + const mockMySegmentsFetcher: IMySegmentsFetcher = jest.fn().mockResolvedValue({ + ms: {}, + ls: {} + } as IMembershipsResponse); + + const mySegmentsUpdater = mySegmentsUpdaterFactory( + loggerMock, + mockMySegmentsFetcher, + storage, + readinessManager.segments, + 1000, + 1, + 'test-key' + ); + + await mySegmentsUpdater(); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); +}); diff --git a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts index acef0f94..0609cc48 100644 --- a/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/segmentChangesUpdater.spec.ts @@ -50,4 +50,86 @@ describe('segmentChangesUpdater', () => { expect(updateSegments).toHaveBeenCalledWith(segmentName, segmentChange.added, segmentChange.removed, segmentChange.till); expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); }); + + test('test with multiple segments update - should emit SEGMENTS_UPDATE metadata once', async () => { + const segment1 = 'segment1'; + const segment2 = 'segment2'; + const segment3 = 'segment3'; + + const segmentChange1: ISegmentChangesResponse = { + name: segment1, + added: ['key1'], + removed: [], + since: -1, + till: 100 + }; + + const segmentChange2: ISegmentChangesResponse = { + name: segment2, + added: ['key2'], + removed: [], + since: -1, + till: 101 + }; + + const segmentChange3: ISegmentChangesResponse = { + name: segment3, + added: ['key3'], + removed: [], + since: -1, + till: 102 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([ + segmentChange1, + segmentChange2, + segmentChange3 + ]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segment1, segment2, segment3]); + + // Update all segments at once + await segmentChangesUpdater(undefined); + + // Should emit once when all segments are updated + expect(segmentsEmitSpy).toHaveBeenCalledTimes(1); + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with empty segments - should still emit SEGMENTS_UPDATE metadata', async () => { + const segmentName = 'empty-segment'; + const segmentChange: ISegmentChangesResponse = { + name: segmentName, + added: [], + removed: [], + since: -1, + till: 123 + }; + + const mockSegmentChangesFetcher: ISegmentChangesFetcher = jest.fn().mockResolvedValue([segmentChange]); + + const segmentChangesUpdater = segmentChangesUpdaterFactory( + loggerMock, + mockSegmentChangesFetcher, + segments, + readinessManager, + 1000, + 1 + ); + + segments.registerSegments([segmentName]); + + await segmentChangesUpdater(undefined, segmentName); + + expect(segmentsEmitSpy).toBeCalledWith(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); }); diff --git a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts index 3ea8c740..4960f8cc 100644 --- a/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts +++ b/src/sync/polling/updaters/__tests__/splitChangesUpdater.spec.ts @@ -321,4 +321,108 @@ describe('splitChangesUpdater', () => { expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); }); + test('test with multiple flags updated - should emit metadata with all flag names', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + storage.segments.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = true; // Segments ready + + const flag1 = { name: 'flag1', status: 'ACTIVE', changeNumber: 100, conditions: [] } as unknown as ISplit; + const flag2 = { name: 'flag2', status: 'ACTIVE', changeNumber: 101, conditions: [] } as unknown as ISplit; + const flag3 = { name: 'flag3', status: 'ACTIVE', changeNumber: 102, conditions: [] } as unknown as ISplit; + + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1, flag2, flag3], t: 102 } } }); + await splitChangesUpdater(); + + // Should emit with metadata when splitsArrived is false (first update) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['flag1', 'flag2', 'flag3'] }); + }); + + test('test with ARCHIVED flag - should emit metadata with flag name', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + storage.segments.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = true; // Segments ready + + const archivedFlag = { name: 'archived-flag', status: ARCHIVED_FF, changeNumber: 200, conditions: [] } as unknown as ISplit; + + const payload = archivedFlag as Pick; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: SPLIT_UPDATE })).resolves.toBe(true); + + // Should emit with metadata when splitsArrived is false (first update) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: [payload.name] }); + }); + + test('test with rbsegment payload - should emit SEGMENTS_UPDATE not FLAGS_UPDATE', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.rbSegments.clear(); + + const payload = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + const changeNumber = payload.changeNumber; + + await expect(splitChangesUpdater(undefined, undefined, { payload, changeNumber: changeNumber, type: RB_SEGMENT_UPDATE })).resolves.toBe(true); + + // Should emit SEGMENTS_UPDATE (not FLAGS_UPDATE) when only RB segment is updated + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with only RB segment update and no flags - should emit SEGMENTS_UPDATE', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.splits.clear(); + storage.rbSegments.clear(); + + // Simulate a scenario where only RB segments are updated (no flags) + const rbSegment = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + fetchMock.once('*', { status: 200, body: { rbs: { d: [rbSegment], t: 1684329854385 } } }); + await splitChangesUpdater(); + + // When updatedFlags.length === 0, should emit SEGMENTS_UPDATE + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); + }); + + test('test with both flags and RB segments updated - should emit FLAGS_UPDATE with flag names', async () => { + splitsEmitSpy.mockClear(); + readinessManager.splits.splitsArrived = true; + storage.splits.clear(); + storage.rbSegments.clear(); + storage.segments.clear(); + + // Simulate a scenario where both flags and RB segments are updated + const flag1 = { name: 'flag1', status: 'ACTIVE', changeNumber: 400, conditions: [] } as unknown as ISplit; + const flag2 = { name: 'flag2', status: 'ACTIVE', changeNumber: 401, conditions: [] } as unknown as ISplit; + const rbSegment = { name: 'rbsegment', status: 'ACTIVE', changeNumber: 1684329854385, conditions: [] } as unknown as IRBSegment; + + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1, flag2], t: 401 }, rbs: { d: [rbSegment], t: 1684329854385 } } }); + await splitChangesUpdater(); + + // When both flags and RB segments are updated, should emit FLAGS_UPDATE with flag names + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['flag1', 'flag2'] }); + }); + + test('test client-side behavior - should emit even when segments not all fetched', async () => { + splitsEmitSpy.mockClear(); + storage.splits.clear(); + // Start with splitsArrived = false so it emits on first update + readinessManager.splits.splitsArrived = false; + readinessManager.segments.segmentsArrived = false; // Segments not ready - client-side should still emit + + // Create client-side updater (isClientSide = true) + const clientSideUpdater = splitChangesUpdaterFactory(loggerMock, splitChangesFetcher, storage, splitFiltersValidation, readinessManager.splits, 1000, 1, true); + + const flag1 = { name: 'client-flag', status: 'ACTIVE', changeNumber: 300, conditions: [] } as unknown as ISplit; + fetchMock.once('*', { status: 200, body: { ff: { d: [flag1], t: 300 } } }); + await clientSideUpdater(); + + // Client-side should emit even if segments aren't all fetched (isClientSide bypasses checkAllSegmentsExist) + expect(splitsEmitSpy).toBeCalledWith(SDK_SPLITS_ARRIVED, { type: SdkUpdateMetadataKeys.FLAGS_UPDATE, names: ['client-flag'] }); + }); + }); diff --git a/src/sync/polling/updaters/mySegmentsUpdater.ts b/src/sync/polling/updaters/mySegmentsUpdater.ts index 5421d3f9..119748c3 100644 --- a/src/sync/polling/updaters/mySegmentsUpdater.ts +++ b/src/sync/polling/updaters/mySegmentsUpdater.ts @@ -9,6 +9,7 @@ import { MySegmentsData } from '../types'; import { IMembershipsResponse } from '../../../dtos/types'; import { MEMBERSHIPS_LS_UPDATE } from '../../streaming/constants'; import { usesSegmentsSync } from '../../../storages/AbstractSplitsCacheSync'; +import { SdkUpdateMetadataKeys } from '../../../../types/splitio'; type IMySegmentsUpdater = (segmentsData?: MySegmentsData, noCache?: boolean, till?: number) => Promise @@ -56,7 +57,7 @@ export function mySegmentsUpdaterFactory( // Notify update if required if (usesSegmentsSync(storage) && (shouldNotifyUpdate || readyOnAlreadyExistentState)) { readyOnAlreadyExistentState = false; - segmentsEventEmitter.emit(SDK_SEGMENTS_ARRIVED); + segmentsEventEmitter.emit(SDK_SEGMENTS_ARRIVED, { type: SdkUpdateMetadataKeys.SEGMENTS_UPDATE, names: [] }); } } diff --git a/src/sync/polling/updaters/splitChangesUpdater.ts b/src/sync/polling/updaters/splitChangesUpdater.ts index 5f3511a1..7d57f6bc 100644 --- a/src/sync/polling/updaters/splitChangesUpdater.ts +++ b/src/sync/polling/updaters/splitChangesUpdater.ts @@ -198,8 +198,8 @@ export function splitChangesUpdaterFactory( .catch(() => false /** noop. just to handle a possible `checkAllSegmentsExist` rejection, before emitting SDK event */) .then(emitSplitsArrivedEvent => { const metadata: SdkUpdateMetadata = { - type: ffChanged ? SdkUpdateMetadataKeys.FLAGS_UPDATE : SdkUpdateMetadataKeys.SEGMENTS_UPDATE, - names: ffChanged ? updatedFlags : [] + type: updatedFlags.length > 0 ? SdkUpdateMetadataKeys.FLAGS_UPDATE : SdkUpdateMetadataKeys.SEGMENTS_UPDATE, + names: updatedFlags.length > 0 ? updatedFlags : [] }; // emit SDK events if (emitSplitsArrivedEvent) splitsEventEmitter.emit(SDK_SPLITS_ARRIVED, metadata); diff --git a/types/splitio.d.ts b/types/splitio.d.ts index e908bc13..4204568a 100644 --- a/types/splitio.d.ts +++ b/types/splitio.d.ts @@ -520,6 +520,24 @@ declare namespace SplitIO { names: string[] } + /** + * Metadata for the ready events emitted when the SDK is ready to evaluate feature flags. + */ + type SdkReadyMetadata = { + /** + * Indicates whether the SDK was loaded from cache initially. + * - `true` when SDK_READY_FROM_CACHE is emitted from cache (before SDK_READY) + * - `true` when SDK_READY is emitted and the SDK was ready from cache first + * - `false` when SDK_READY_FROM_CACHE is emitted because SDK became ready without cache + * - `false` when SDK_READY is emitted and the SDK was not ready from cache + */ + initialCacheLoad: boolean + /** + * Timestamp in milliseconds since epoch when the event was emitted. + */ + lastUpdateTimestamp: number + } + /** * EventEmitter interface based on a subset of the Node.js EventEmitter methods. */ @@ -537,10 +555,16 @@ declare namespace SplitIO { * @see {@link https://nodejs.org/api/events.html} */ interface EventEmitter extends IEventEmitter { + addListener(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + addListener(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; addListener(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; addListener(event: string | symbol, listener: (...args: any[]) => void): this; + on(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + on(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; on(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; on(event: string | symbol, listener: (...args: any[]) => void): this; + once(event: EventConsts['SDK_READY'], listener: (metadata: SdkReadyMetadata) => void): this; + once(event: EventConsts['SDK_READY_FROM_CACHE'], listener: (metadata: SdkReadyMetadata) => void): this; once(event: EventConsts['SDK_UPDATE'], listener: (metadata: SdkUpdateMetadata) => void): this; once(event: string | symbol, listener: (...args: any[]) => void): this; removeListener(event: string | symbol, listener: (...args: any[]) => void): this;