diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/DataSourceUpdateSink.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/DataSourceUpdateSink.test.ts new file mode 100644 index 0000000000..95e6628a44 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/DataSourceUpdateSink.test.ts @@ -0,0 +1,291 @@ +import { Context, internal } from '@launchdarkly/js-sdk-common'; + +import { createDataSourceUpdateSink } from '../../../src/datasource/fdv2/DataSourceUpdateSink'; +import { ChangeSetResult } from '../../../src/datasource/fdv2/FDv2SourceResult'; +import { FlagManager } from '../../../src/flag-manager/FlagManager'; +import { makeLogger } from './orchestrationTestHelpers'; + +function makeContext(key: string = 'user-key'): Context { + return Context.fromLDContext({ kind: 'user', key }); +} + +function makeFlagManager(): jest.Mocked> { + return { + applyChanges: jest.fn().mockResolvedValue(undefined), + }; +} + +function makeResult( + payload: internal.Payload, + opts: { fdv1Fallback?: boolean; environmentId?: string } = {}, +): ChangeSetResult { + return { + type: 'changeSet', + payload, + fdv1Fallback: opts.fdv1Fallback ?? false, + environmentId: opts.environmentId, + }; +} + +function makePayload(overrides: Partial = {}): internal.Payload { + return { + id: 'test-payload', + version: 1, + state: 'test-selector', + type: 'full', + updates: [], + ...overrides, + }; +} + +function makeFlagEvalUpdate( + key: string, + version: number, + value: unknown, + opts: { deleted?: boolean } = {}, +): internal.Update { + if (opts.deleted) { + return { kind: 'flagEval', key, version, deleted: true }; + } + return { + kind: 'flagEval', + key, + version, + object: { value, trackEvents: false }, + }; +} + +// -- full payload delegation -- + +it('delegates full payload to flagManager.applyChanges with basis=true', () => { + const flagManager = makeFlagManager(); + const context = makeContext(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => context, + }); + + const payload = makePayload({ + type: 'full', + state: 'sel-1', + updates: [makeFlagEvalUpdate('flag-1', 10, true), makeFlagEvalUpdate('flag-2', 20, 'hello')], + }); + + sink.handleChangeSet(makeResult(payload, { environmentId: 'env-1' })); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + context, + { + 'flag-1': { version: 10, flag: { value: true, trackEvents: false, version: 10 } }, + 'flag-2': { version: 20, flag: { value: 'hello', trackEvents: false, version: 20 } }, + }, + true, + 'sel-1', + 'env-1', + ); +}); + +// -- partial payload delegation -- + +it('delegates partial payload to flagManager.applyChanges with basis=false', () => { + const flagManager = makeFlagManager(); + const context = makeContext(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => context, + }); + + const payload = makePayload({ + type: 'partial', + state: 'sel-2', + updates: [makeFlagEvalUpdate('flag-a', 5, 42)], + }); + + sink.handleChangeSet(makeResult(payload)); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + context, + { 'flag-a': { version: 5, flag: { value: 42, trackEvents: false, version: 5 } } }, + false, + 'sel-2', + undefined, + ); +}); + +// -- none payload delegation -- + +it('delegates none payload to flagManager.applyChanges with empty updates', () => { + const flagManager = makeFlagManager(); + const context = makeContext(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => context, + }); + + sink.handleChangeSet( + makeResult(makePayload({ type: 'none', state: 'sel-3', updates: [] }), { + environmentId: 'env-2', + }), + ); + + expect(flagManager.applyChanges).toHaveBeenCalledWith(context, {}, false, 'sel-3', 'env-2'); +}); + +// -- selector passthrough -- + +it('passes undefined selector when payload state is empty string', () => { + const flagManager = makeFlagManager(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => makeContext(), + }); + + sink.handleChangeSet(makeResult(makePayload({ state: '' }))); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + true, + undefined, + undefined, + ); +}); + +it('passes undefined selector when payload state is undefined', () => { + const flagManager = makeFlagManager(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => makeContext(), + }); + + sink.handleChangeSet(makeResult(makePayload({ state: undefined }))); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + true, + undefined, + undefined, + ); +}); + +// -- non-flagEval kinds -- + +it('ignores non-flagEval update kinds in conversion', () => { + const flagManager = makeFlagManager(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => makeContext(), + }); + + const payload = makePayload({ + type: 'full', + updates: [ + { kind: 'segment', key: 'seg-1', version: 1, object: {} }, + { kind: 'unknown', key: 'unk-1', version: 1, object: {} }, + ], + }); + + sink.handleChangeSet(makeResult(payload)); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + expect.anything(), + {}, + true, + expect.anything(), + undefined, + ); +}); + +// -- delete handling -- + +it('converts delete updates to tombstone descriptors', () => { + const flagManager = makeFlagManager(); + const context = makeContext(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => context, + }); + + const payload = makePayload({ + type: 'full', + updates: [makeFlagEvalUpdate('deleted-flag', 7, undefined, { deleted: true })], + }); + + sink.handleChangeSet(makeResult(payload)); + + expect(flagManager.applyChanges).toHaveBeenCalledWith( + context, + { + 'deleted-flag': { + version: 7, + flag: { version: 7, deleted: true, value: undefined, trackEvents: false }, + }, + }, + true, + expect.anything(), + undefined, + ); +}); + +// -- context getter -- + +it('uses current context from getter on each call', () => { + const flagManager = makeFlagManager(); + const contextA = makeContext('user-a'); + const contextB = makeContext('user-b'); + let currentContext = contextA; + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => currentContext, + }); + + sink.handleChangeSet(makeResult(makePayload({ type: 'full', updates: [] }))); + expect(flagManager.applyChanges).toHaveBeenLastCalledWith( + contextA, + {}, + true, + expect.anything(), + undefined, + ); + + currentContext = contextB; + sink.handleChangeSet(makeResult(makePayload({ type: 'full', updates: [] }))); + expect(flagManager.applyChanges).toHaveBeenLastCalledWith( + contextB, + {}, + true, + expect.anything(), + undefined, + ); +}); + +// -- logging -- + +it('logs debug messages for each payload type', () => { + const flagManager = makeFlagManager(); + const logger = makeLogger(); + + const sink = createDataSourceUpdateSink({ + flagManager: flagManager as unknown as FlagManager, + contextGetter: () => makeContext(), + logger, + }); + + sink.handleChangeSet(makeResult(makePayload({ type: 'full', updates: [] }))); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('full')); + + sink.handleChangeSet(makeResult(makePayload({ type: 'partial', updates: [] }))); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('partial')); + + sink.handleChangeSet(makeResult(makePayload({ type: 'none', updates: [] }))); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('none')); +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts index 13e6fc0b88..aa86741e80 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts @@ -41,7 +41,7 @@ it('resolves start() when initializer returns changeSet with selector', async () await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); expect(statusManager.requestStateUpdate).toHaveBeenCalledWith('INITIALIZING'); expect(statusManager.requestStateUpdate).toHaveBeenCalledWith('VALID'); ds.close(); @@ -114,7 +114,7 @@ it('continues past initializer errors', async () => { expect(logger.warn).toHaveBeenCalled(); expect(statusManager.reportError).toHaveBeenCalled(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -135,7 +135,7 @@ it('continues past terminal errors in initializers', async () => { }); await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -157,7 +157,7 @@ it('skips to synchronizers when no initializers are configured', async () => { await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -181,7 +181,7 @@ it('delivers changeSet from synchronizer to callback', async () => { await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); expect(statusManager.requestStateUpdate).toHaveBeenCalledWith('VALID'); ds.close(); }); @@ -213,7 +213,7 @@ it('blocks synchronizer on terminal error and moves to next', async () => { expect(logger.error).toHaveBeenCalled(); expect(statusManager.reportError).toHaveBeenCalled(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -239,7 +239,7 @@ it('continues on interrupted results from synchronizer', async () => { await ds.start(); expect(statusManager.reportError).toHaveBeenCalled(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -261,7 +261,7 @@ it('continues on goodbye results from synchronizer', async () => { await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -315,8 +315,8 @@ it('triggers fdv1 fallback when synchronizer changeSet has fdv1Fallback flag', a await statusManager.waitForState('VALID', 2); expect(dataCallback).toHaveBeenCalledTimes(2); - expect(dataCallback).toHaveBeenCalledWith(fdv2Payload); - expect(dataCallback).toHaveBeenCalledWith(fdv1Payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload: fdv2Payload })); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload: fdv1Payload })); ds.close(); }); @@ -348,7 +348,7 @@ it('triggers fdv1 fallback on terminal error with fdv1Fallback flag', async () = await ds.start(); expect(logger.error).toHaveBeenCalled(); - expect(dataCallback).toHaveBeenCalledWith(fdv1Payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload: fdv1Payload })); ds.close(); }); @@ -398,7 +398,7 @@ it('falls back to next synchronizer when fallback condition fires', async () => await ds.start(); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fallback condition fired')); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -471,7 +471,7 @@ it('recovers to primary synchronizer when recovery condition fires', async () => await ds.start(); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Recovery condition fired')); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -614,7 +614,7 @@ it('resolves with initializer data even when no synchronizers exist', async () = await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -678,9 +678,9 @@ it('delivers multiple changeSets from synchronizer in order', async () => { await statusManager.waitForState('VALID', 3); expect(dataCallback).toHaveBeenCalledTimes(3); - expect(dataCallback).toHaveBeenNthCalledWith(1, payload1); - expect(dataCallback).toHaveBeenNthCalledWith(2, payload2); - expect(dataCallback).toHaveBeenNthCalledWith(3, payload3); + expect(dataCallback).toHaveBeenNthCalledWith(1, expect.objectContaining({ payload: payload1 })); + expect(dataCallback).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: payload2 })); + expect(dataCallback).toHaveBeenNthCalledWith(3, expect.objectContaining({ payload: payload3 })); ds.close(); }); @@ -709,7 +709,7 @@ it('first initializer with selector prevents second initializer from running', a await ds.start(); expect(dataCallback).toHaveBeenCalledTimes(1); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); expect(secondInitFactory).not.toHaveBeenCalled(); ds.close(); }); @@ -804,7 +804,7 @@ it('fdv1 fallback not triggered when fdv1Fallback flag is absent', async () => { await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); expect(fdv1Factory).not.toHaveBeenCalled(); ds.close(); }); @@ -840,7 +840,7 @@ it('fdv1 fallback blocks other synchronizers', async () => { // FDv1 fallback should block non-FDv1 synchronizers — second sync should not be called expect(secondSyncFactory).not.toHaveBeenCalled(); - expect(dataCallback).toHaveBeenCalledWith(fdv1Payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload: fdv1Payload })); ds.close(); }); @@ -864,7 +864,7 @@ it('fdv1 fallback ignored when no FDv1 synchronizer is configured', async () => await ds.start(); // Should process the changeSet normally without error - expect(dataCallback).toHaveBeenCalledWith(payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload })); ds.close(); }); @@ -892,6 +892,6 @@ it('fdv1 fallback triggered on interrupted result with fdv1Fallback flag', async // Start resolves when the fdv1 synchronizer delivers its changeSet. await ds.start(); - expect(dataCallback).toHaveBeenCalledWith(fdv1Payload); + expect(dataCallback).toHaveBeenCalledWith(expect.objectContaining({ payload: fdv1Payload })); ds.close(); }); diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts index d8fe440004..6134bb9950 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -87,6 +87,128 @@ function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): }; } +describe('given a flag manager', () => { + let flagManager: DefaultFlagManager; + let mockPlatform: Platform; + let mockLogger: LDLogger; + + beforeEach(() => { + mockLogger = makeMockLogger(); + mockPlatform = makeMockPlatform(makeMemoryStorage(), makeMockCrypto()); + flagManager = new DefaultFlagManager( + mockPlatform, + TEST_SDK_KEY, + TEST_MAX_CACHED_CONTEXTS, + mockLogger, + ); + }); + + it('applies a full changeset replacing all flags', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + + await flagManager.applyChanges( + context, + { + 'flag-a': makeMockItemDescriptor(1, 'a'), + 'flag-b': makeMockItemDescriptor(2, 'b'), + }, + true, + ); + + expect(flagManager.get('flag-a')?.flag.value).toBe('a'); + expect(flagManager.get('flag-b')?.flag.value).toBe('b'); + }); + + it('applies a full changeset that replaces previous flags', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + + await flagManager.applyChanges(context, { old: makeMockItemDescriptor(1, 'old') }, true); + await flagManager.applyChanges(context, { new: makeMockItemDescriptor(2, 'new') }, true); + + expect(flagManager.get('old')).toBeUndefined(); + expect(flagManager.get('new')?.flag.value).toBe('new'); + }); + + it('applies a partial changeset upserting individual flags', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + + await flagManager.applyChanges( + context, + { + 'flag-a': makeMockItemDescriptor(1, 'a'), + 'flag-b': makeMockItemDescriptor(1, 'b'), + }, + true, + ); + + await flagManager.applyChanges(context, { 'flag-b': makeMockItemDescriptor(2, 'b2') }, false); + + expect(flagManager.get('flag-a')?.flag.value).toBe('a'); + expect(flagManager.get('flag-b')?.flag.value).toBe('b2'); + }); + + it('applies a partial changeset that adds new flags', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + + await flagManager.applyChanges(context, { existing: makeMockItemDescriptor(1, 'e') }, true); + await flagManager.applyChanges(context, { added: makeMockItemDescriptor(1, 'new') }, false); + + expect(flagManager.get('existing')?.flag.value).toBe('e'); + expect(flagManager.get('added')?.flag.value).toBe('new'); + }); + + it('applies empty changeset without error', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, false); + expect(Object.keys(flagManager.getAll())).toHaveLength(0); + }); + + it('returns undefined for selector and environmentId initially', () => { + expect(flagManager.getSelector()).toBeUndefined(); + expect(flagManager.getEnvironmentId()).toBeUndefined(); + }); + + it('stores selector from applyChanges', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, 'sel-1'); + expect(flagManager.getSelector()).toBe('sel-1'); + }); + + it('updates selector on subsequent applyChanges', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, 'sel-1'); + await flagManager.applyChanges(context, {}, false, 'sel-2'); + expect(flagManager.getSelector()).toBe('sel-2'); + }); + + it('does not clear selector when not provided', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, 'sel-1'); + await flagManager.applyChanges(context, {}, false); + expect(flagManager.getSelector()).toBe('sel-1'); + }); + + it('stores environmentId from applyChanges', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, undefined, 'env-123'); + expect(flagManager.getEnvironmentId()).toBe('env-123'); + }); + + it('updates environmentId on subsequent applyChanges', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, undefined, 'env-1'); + await flagManager.applyChanges(context, {}, false, undefined, 'env-2'); + expect(flagManager.getEnvironmentId()).toBe('env-2'); + }); + + it('does not clear environmentId when not provided', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.applyChanges(context, {}, true, undefined, 'env-1'); + await flagManager.applyChanges(context, {}, false); + expect(flagManager.getEnvironmentId()).toBe('env-1'); + }); +}); + describe('FlagManager override tests', () => { let flagManager: DefaultFlagManager; let mockPlatform: Platform; diff --git a/packages/shared/sdk-client/src/datasource/fdv2/DataSourceUpdateSink.ts b/packages/shared/sdk-client/src/datasource/fdv2/DataSourceUpdateSink.ts new file mode 100644 index 0000000000..572d5e104a --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/fdv2/DataSourceUpdateSink.ts @@ -0,0 +1,70 @@ +import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { FlagManager } from '../../flag-manager/FlagManager'; +import { flagEvalPayloadToItemDescriptors } from '../flagEvalMapper'; +import { ChangeSetResult } from './FDv2SourceResult'; + +/** + * Configuration for creating a {@link DataSourceUpdateSink}. + */ +export interface DataSourceUpdateSinkConfig { + /** FlagManager to apply flag updates to. */ + flagManager: FlagManager; + + /** + * Getter for the current evaluation context. A getter is used instead of a + * stored context because the context changes on `identify()`. + */ + contextGetter: () => Context; + + /** Optional logger. */ + logger?: LDLogger; +} + +/** + * Thin adapter between the FDv2 protocol layer and {@link FlagManager}. + * Converts FDv2 protocol types (payload updates) to {@link ItemDescriptor} + * format and delegates to {@link FlagManager.applyChanges}. + * + * Selector and environmentId are managed by FlagManager, not the sink. + */ +export interface DataSourceUpdateSink { + /** + * Processes a {@link ChangeSetResult} and applies flag updates to + * FlagManager. Pass this as the `dataCallback` to {@link FDv2DataSource}. + */ + handleChangeSet(result: ChangeSetResult): void; +} + +/** + * Creates a {@link DataSourceUpdateSink}. + */ +export function createDataSourceUpdateSink( + config: DataSourceUpdateSinkConfig, +): DataSourceUpdateSink { + const { flagManager, contextGetter, logger } = config; + + return { + handleChangeSet(result: ChangeSetResult): void { + const { payload } = result; + const context = contextGetter(); + const selector = payload.state || undefined; + const { environmentId } = result; + + if (payload.type === 'none') { + logger?.debug('FDv2 payload type "none": no flag updates needed'); + flagManager.applyChanges(context, {}, false, selector, environmentId); + return; + } + + const descriptors = flagEvalPayloadToItemDescriptors(payload.updates); + const basis = payload.type === 'full'; + + logger?.debug( + `FDv2 ${payload.type} payload: ${basis ? 'initializing' : 'upserting'} ${Object.keys(descriptors).length} flags`, + ); + + flagManager.applyChanges(context, descriptors, basis, selector, environmentId); + }, + }; +} diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts index d8c122c908..2682f78766 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts @@ -1,4 +1,4 @@ -import { internal, LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDLogger } from '@launchdarkly/js-sdk-common'; import { DataSourceStatusManager } from '../DataSourceStatusManager'; import { @@ -12,9 +12,11 @@ import { ChangeSetResult, FDv2SourceResult, StatusResult } from './FDv2SourceRes import { createSourceManager, InitializerFactory, SynchronizerSlot } from './SourceManager'; /** - * Callback invoked when the orchestrator produces a changeSet payload. + * Callback invoked when the orchestrator produces a changeSet result. + * Receives the full {@link ChangeSetResult} so consumers have access to both + * the payload and metadata (environmentId, fdv1Fallback). */ -export type DataCallback = (payload: internal.Payload) => void; +export type DataCallback = (result: ChangeSetResult) => void; /** * Configuration for the {@link FDv2DataSource} orchestrator. @@ -107,7 +109,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour } function applyChangeSet(result: ChangeSetResult) { - dataCallback(result.payload); + dataCallback(result); statusManager.requestStateUpdate('VALID'); } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/index.ts b/packages/shared/sdk-client/src/datasource/fdv2/index.ts index 78879c5ff9..136026bfe6 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/index.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/index.ts @@ -1,6 +1,9 @@ export type { AsyncQueue } from './AsyncQueue'; export { createAsyncQueue } from './AsyncQueue'; +export type { DataSourceUpdateSink, DataSourceUpdateSinkConfig } from './DataSourceUpdateSink'; +export { createDataSourceUpdateSink } from './DataSourceUpdateSink'; + export type { FDv2PollResponse, FDv2Requestor } from './FDv2Requestor'; export { makeFDv2Requestor } from './FDv2Requestor'; diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 094819bf56..b9702873ba 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -55,6 +55,39 @@ export interface FlagManager { */ setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + /** + * Applies a changeset to the flag store. If {@link basis} is true, replaces + * all flags (like {@link init}). If false, upserts individual flags (like + * calling {@link upsert} for each entry). + * + * Stores the {@link selector} in memory for use as the `basis` query + * parameter on subsequent requests. The selector is NOT persisted with + * cache (Requirement 6.2.1). Tracks {@link environmentId} from response + * headers (Requirement 4.2.1). + * + * This follows the same pattern as the server SDK's + * `LDTransactionalFeatureStore.applyChanges`. + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + basis: boolean, + selector?: string, + environmentId?: string, + ): Promise; + + /** + * Returns the current selector string for use as the `basis` query + * parameter. Returns undefined if no selector has been received yet. + * The selector is stored in memory only (Requirement 6.2.1). + */ + getSelector(): string | undefined; + + /** + * Returns the environment ID from the most recent changeset. + */ + getEnvironmentId(): string | undefined; + /** * Register a flag change callback. */ @@ -121,6 +154,8 @@ export default class DefaultFlagManager implements FlagManager { private _flagUpdater: FlagUpdater; private _flagPersistencePromise: Promise; private _overrides?: { [key: string]: LDFlagValue }; + private _selector?: string; + private _environmentId?: string; /** * @param platform implementation of various platform provided functionality @@ -212,6 +247,30 @@ export default class DefaultFlagManager implements FlagManager { return (await this._flagPersistencePromise).loadCached(context); } + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + basis: boolean, + selector?: string, + environmentId?: string, + ): Promise { + if (selector) { + this._selector = selector; + } + if (environmentId) { + this._environmentId = environmentId; + } + return (await this._flagPersistencePromise).applyChanges(context, updates, basis); + } + + getSelector(): string | undefined { + return this._selector; + } + + getEnvironmentId(): string | undefined { + return this._environmentId; + } + on(callback: FlagsChangeCallback): void { this._flagUpdater.on(callback); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index 118b35df3b..6e2c95ebb5 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -51,6 +51,26 @@ export default class FlagPersistence { return false; } + /** + * Applies a changeset to the flag store. If {@link basis} is true, replaces all + * flags via {@link FlagUpdater.init}. If false, upserts individual flags via + * {@link FlagUpdater.upsert}. Persists changes after applying. + */ + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + basis: boolean, + ): Promise { + if (basis) { + this._flagUpdater.init(context, updates); + } else { + Object.entries(updates).forEach(([key, descriptor]) => { + this._flagUpdater.upsert(context, key, descriptor); + }); + } + await this._storeCache(context); + } + /** * Loads the flags from persistence for the provided context and gives those to the * {@link FlagUpdater} this {@link FlagPersistence} was constructed with.