diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index be1be7e032..3cccf15cae 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -41,7 +41,7 @@ jobs: target_file: 'packages/sdk/browser/dist/index.js' package_name: '@launchdarkly/js-client-sdk' pr_number: ${{ github.event.number }} - size_limit: 25000 + size_limit: 34000 # Contract Tests - name: Install contract test dependencies 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 5cb1fb0c76..4bad1d99a7 100644 --- a/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts @@ -3,6 +3,7 @@ import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import DefaultFlagManager from '../../src/flag-manager/FlagManager'; import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; import { + makeIncrementingStamper, makeMemoryStorage, makeMockCrypto, makeMockItemDescriptor, @@ -177,3 +178,101 @@ describe('FlagManager override tests', () => { expect(allFlags['override-only-flag'].flag.value).toBe('override-value'); }); }); + +describe('given a flag manager with storage', () => { + let flagManager: DefaultFlagManager; + let mockPlatform: Platform; + let mockLogger: LDLogger; + let storage: ReturnType; + + beforeEach(() => { + mockLogger = makeMockLogger(); + storage = makeMemoryStorage(); + mockPlatform = makeMockPlatform(storage, makeMockCrypto()); + flagManager = new DefaultFlagManager( + mockPlatform, + TEST_SDK_KEY, + TEST_MAX_CACHED_CONTEXTS, + false, + mockLogger, + makeIncrementingStamper(), + ); + }); + + it('replaces all flags when applyChanges is called with type full', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges( + context, + { 'new-flag': makeMockItemDescriptor(2, 'new') }, + 'full', + ); + + // type=full replaces, so existing flag should be gone. + expect(flagManager.get('existing')).toBeUndefined(); + expect(flagManager.get('new-flag')?.flag.value).toBe('new'); + }); + + it('upserts individual flags when applyChanges is called with type partial', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + existing: makeMockItemDescriptor(1, 'old'), + }); + + await flagManager.applyChanges(context, { added: makeMockItemDescriptor(2, 'new') }, 'partial'); + + // type=partial upserts, so existing flag should remain. + expect(flagManager.get('existing')?.flag.value).toBe('old'); + expect(flagManager.get('added')?.flag.value).toBe('new'); + }); + + it('persists cache when applyChanges is called with type none', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(1, 'value'), + }); + + // applyChanges with type none should still persist (updating freshness). + await flagManager.applyChanges(context, {}, 'none'); + + // Flag should still be present (no changes). + expect(flagManager.get('flag1')?.flag.value).toBe('value'); + }); + + it('partial applyChanges bypasses version checks (accepts older version)', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(10, 'v10'), + }); + + // FDv1 upsert would reject version 5 because 5 < 10. + // FDv2 applyChanges should accept it — protocol handles ordering. + await flagManager.applyChanges( + context, + { flag1: makeMockItemDescriptor(5, 'v5-override') }, + 'partial', + ); + + expect(flagManager.get('flag1')?.flag.value).toBe('v5-override'); + }); + + it('applyChanges emits change events for partial updates', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'user-key' }); + await flagManager.init(context, { + flag1: makeMockItemDescriptor(1, 'old'), + }); + + const changes: string[][] = []; + flagManager.on((_ctx, keys) => { + changes.push(keys); + }); + + await flagManager.applyChanges(context, { flag1: makeMockItemDescriptor(2, 'new') }, 'partial'); + + expect(changes).toHaveLength(1); + expect(changes[0]).toContain('flag1'); + }); +}); diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index 273b623a08..0ae031594c 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -1,4 +1,4 @@ -import { Context, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { namespaceForEnvironment } from '../storage/namespaceUtils'; import FlagPersistence from './FlagPersistence'; @@ -55,6 +55,21 @@ export interface FlagManager { */ setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags (like {@link init}). + * - `'partial'`: upserts individual flags (like calling {@link upsert} for each entry). + * - `'none'`: persists cache (updating freshness) without changing any flags. + * + * Designed for the FDv2 data path where init/upsert semantics, selector + * tracking, and freshness updates are all handled in one call. + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise; + /** * Register a flag change callback. */ @@ -217,6 +232,14 @@ export default class DefaultFlagManager implements FlagManager { return (await this._flagPersistencePromise).loadCached(context); } + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + return (await this._flagPersistencePromise).applyChanges(context, updates, type); + } + 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 df41882309..9827dd5af6 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDLogger, Platform } from '@launchdarkly/js-sdk-common'; import { FRESHNESS_SUFFIX, FreshnessRecord, hashContext } from '../storage/freshness'; import { loadCachedFlags } from '../storage/loadCachedFlags'; @@ -59,6 +59,24 @@ export default class FlagPersistence { return false; } + /** + * Applies a changeset to the flag store. + * - `'full'`: replaces all flags via {@link FlagUpdater.init}. + * - `'partial'`: upserts individual flags via {@link FlagUpdater.upsert}. + * - `'none'`: no flag changes, only persists cache to update freshness. + * + * Always persists to cache afterwards, which updates the freshness timestamp + * even when no flags change (e.g., transfer-none). + */ + async applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): Promise { + this._flagUpdater.applyChanges(context, updates, type); + 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. diff --git a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts index d3051a963e..8b2f5605a9 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { ItemDescriptor } from './ItemDescriptor'; /** @@ -20,6 +22,14 @@ export default interface FlagStore { * Gets all the flags in the flag store. */ getAll(): { [key: string]: ItemDescriptor }; + + /** + * Applies a changeset to the store without version checks. + * - `'full'`: replaces all flags. + * - `'partial'`: merges updates into existing flags. + * - `'none'`: no-op. + */ + applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType): void; } /** @@ -49,5 +59,14 @@ export function createDefaultFlagStore(): FlagStore { getAll(): { [key: string]: ItemDescriptor } { return flags; }, + applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType) { + if (type === 'full') { + this.init(updates); + } else if (type === 'partial') { + Object.entries(updates).forEach(([key, descriptor]) => { + flags[key] = descriptor; + }); + } + }, }; } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index da6cd5b8e0..79f26ea88b 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -1,4 +1,4 @@ -import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; +import { Context, internal, LDLogger } from '@launchdarkly/js-sdk-common'; import calculateChangedKeys from './calculateChangedKeys'; import FlagStore from './FlagStore'; @@ -60,6 +60,21 @@ export interface FlagUpdater { */ upsert(context: Context, key: string, item: ItemDescriptor): boolean; + /** + * Applies a changeset directly to the flag store, bypassing version checks + * and inactive-context guards. Used by the FDv2 data path where ordering + * is handled at the protocol layer. + * + * - `'full'`: replaces all flags and emits change events for differences. + * - `'partial'`: merges updates and emits change events for each key. + * - `'none'`: no flag changes (caller handles freshness separately). + */ + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): void; + /** * Registers a callback to be called when the flags change. * @@ -115,6 +130,29 @@ export default function createFlagUpdater(_flagStore: FlagStore, _logger: LDLogg this.init(context, newFlags); }, + applyChanges( + context: Context, + updates: { [key: string]: ItemDescriptor }, + type: internal.PayloadType, + ): void { + activeContext = context; + const oldFlags = flagStore.getAll(); + flagStore.applyChanges(updates, type); + + if (type === 'full') { + const changed = calculateChangedKeys(oldFlags, updates); + if (changed.length > 0) { + this.handleFlagChanges(changed, 'init'); + } + } else if (type === 'partial') { + const keys = Object.keys(updates); + if (keys.length > 0) { + this.handleFlagChanges(keys, 'patch'); + } + } + // 'none' — no flag changes, caller handles freshness. + }, + upsert(context: Context, key: string, item: ItemDescriptor): boolean { if (activeContext?.canonicalKey !== context.canonicalKey) { logger.warn('Received an update for an inactive context.');