Skip to content

Commit d9a1bd7

Browse files
authored
feat: FlagManager.applyChanges for FDv2 full/partial/none semantics (#1208)
Review 1207 first. ## Summary - Add `applyChanges(context, updates, type)` method to `FlagManager` interface and `FlagPersistence` - `'full'`: replaces all flags (like init) - `'partial'`: upserts individual flags - `'none'`: persists cache (updating freshness) without changing flags Stacked on #1207. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches core flag update/caching behavior by adding a new update path that bypasses version and active-context checks, which could affect consistency if misused. Changes are contained and covered by new unit tests, plus a CI-only workflow tweak. > > **Overview** > Adds a new `FlagManager.applyChanges(context, updates, type)` API (and plumbing through `FlagPersistence`, `FlagUpdater`, and `FlagStore`) to support FDv2 update semantics: **`full` replace**, **`partial` merge/upsert without version/active-context guards**, and **`none` no-op flag changes while still persisting cache/freshness**. > > Updates change-event emission to match the new modes (`init` for full diffs, `patch` for partial key lists) and adds unit tests covering replacement vs merge behavior, freshness persistence on `none`, version-check bypass, and change callbacks. > > Separately relaxes the browser CI package-size gate by raising the limit from `25000` to `34000`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b5e5dc3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1208" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent e254f77 commit d9a1bd7

6 files changed

Lines changed: 201 additions & 4 deletions

File tree

.github/workflows/browser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
target_file: 'packages/sdk/browser/dist/index.js'
4242
package_name: '@launchdarkly/js-client-sdk'
4343
pr_number: ${{ github.event.number }}
44-
size_limit: 25000
44+
size_limit: 34000
4545

4646
# Contract Tests
4747
- name: Install contract test dependencies

packages/shared/sdk-client/__tests__/flag-manager/FlagManager.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
33
import DefaultFlagManager from '../../src/flag-manager/FlagManager';
44
import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater';
55
import {
6+
makeIncrementingStamper,
67
makeMemoryStorage,
78
makeMockCrypto,
89
makeMockItemDescriptor,
@@ -177,3 +178,101 @@ describe('FlagManager override tests', () => {
177178
expect(allFlags['override-only-flag'].flag.value).toBe('override-value');
178179
});
179180
});
181+
182+
describe('given a flag manager with storage', () => {
183+
let flagManager: DefaultFlagManager;
184+
let mockPlatform: Platform;
185+
let mockLogger: LDLogger;
186+
let storage: ReturnType<typeof makeMemoryStorage>;
187+
188+
beforeEach(() => {
189+
mockLogger = makeMockLogger();
190+
storage = makeMemoryStorage();
191+
mockPlatform = makeMockPlatform(storage, makeMockCrypto());
192+
flagManager = new DefaultFlagManager(
193+
mockPlatform,
194+
TEST_SDK_KEY,
195+
TEST_MAX_CACHED_CONTEXTS,
196+
false,
197+
mockLogger,
198+
makeIncrementingStamper(),
199+
);
200+
});
201+
202+
it('replaces all flags when applyChanges is called with type full', async () => {
203+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
204+
await flagManager.init(context, {
205+
existing: makeMockItemDescriptor(1, 'old'),
206+
});
207+
208+
await flagManager.applyChanges(
209+
context,
210+
{ 'new-flag': makeMockItemDescriptor(2, 'new') },
211+
'full',
212+
);
213+
214+
// type=full replaces, so existing flag should be gone.
215+
expect(flagManager.get('existing')).toBeUndefined();
216+
expect(flagManager.get('new-flag')?.flag.value).toBe('new');
217+
});
218+
219+
it('upserts individual flags when applyChanges is called with type partial', async () => {
220+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
221+
await flagManager.init(context, {
222+
existing: makeMockItemDescriptor(1, 'old'),
223+
});
224+
225+
await flagManager.applyChanges(context, { added: makeMockItemDescriptor(2, 'new') }, 'partial');
226+
227+
// type=partial upserts, so existing flag should remain.
228+
expect(flagManager.get('existing')?.flag.value).toBe('old');
229+
expect(flagManager.get('added')?.flag.value).toBe('new');
230+
});
231+
232+
it('persists cache when applyChanges is called with type none', async () => {
233+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
234+
await flagManager.init(context, {
235+
flag1: makeMockItemDescriptor(1, 'value'),
236+
});
237+
238+
// applyChanges with type none should still persist (updating freshness).
239+
await flagManager.applyChanges(context, {}, 'none');
240+
241+
// Flag should still be present (no changes).
242+
expect(flagManager.get('flag1')?.flag.value).toBe('value');
243+
});
244+
245+
it('partial applyChanges bypasses version checks (accepts older version)', async () => {
246+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
247+
await flagManager.init(context, {
248+
flag1: makeMockItemDescriptor(10, 'v10'),
249+
});
250+
251+
// FDv1 upsert would reject version 5 because 5 < 10.
252+
// FDv2 applyChanges should accept it — protocol handles ordering.
253+
await flagManager.applyChanges(
254+
context,
255+
{ flag1: makeMockItemDescriptor(5, 'v5-override') },
256+
'partial',
257+
);
258+
259+
expect(flagManager.get('flag1')?.flag.value).toBe('v5-override');
260+
});
261+
262+
it('applyChanges emits change events for partial updates', async () => {
263+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
264+
await flagManager.init(context, {
265+
flag1: makeMockItemDescriptor(1, 'old'),
266+
});
267+
268+
const changes: string[][] = [];
269+
flagManager.on((_ctx, keys) => {
270+
changes.push(keys);
271+
});
272+
273+
await flagManager.applyChanges(context, { flag1: makeMockItemDescriptor(2, 'new') }, 'partial');
274+
275+
expect(changes).toHaveLength(1);
276+
expect(changes[0]).toContain('flag1');
277+
});
278+
});

packages/shared/sdk-client/src/flag-manager/FlagManager.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Context, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
1+
import { Context, internal, LDFlagValue, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
22

33
import { namespaceForEnvironment } from '../storage/namespaceUtils';
44
import FlagPersistence from './FlagPersistence';
@@ -55,6 +55,21 @@ export interface FlagManager {
5555
*/
5656
setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void;
5757

58+
/**
59+
* Applies a changeset to the flag store.
60+
* - `'full'`: replaces all flags (like {@link init}).
61+
* - `'partial'`: upserts individual flags (like calling {@link upsert} for each entry).
62+
* - `'none'`: persists cache (updating freshness) without changing any flags.
63+
*
64+
* Designed for the FDv2 data path where init/upsert semantics, selector
65+
* tracking, and freshness updates are all handled in one call.
66+
*/
67+
applyChanges(
68+
context: Context,
69+
updates: { [key: string]: ItemDescriptor },
70+
type: internal.PayloadType,
71+
): Promise<void>;
72+
5873
/**
5974
* Register a flag change callback.
6075
*/
@@ -217,6 +232,14 @@ export default class DefaultFlagManager implements FlagManager {
217232
return (await this._flagPersistencePromise).loadCached(context);
218233
}
219234

235+
async applyChanges(
236+
context: Context,
237+
updates: { [key: string]: ItemDescriptor },
238+
type: internal.PayloadType,
239+
): Promise<void> {
240+
return (await this._flagPersistencePromise).applyChanges(context, updates, type);
241+
}
242+
220243
on(callback: FlagsChangeCallback): void {
221244
this._flagUpdater.on(callback);
222245
}

packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Context, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
1+
import { Context, internal, LDLogger, Platform } from '@launchdarkly/js-sdk-common';
22

33
import { FRESHNESS_SUFFIX, FreshnessRecord, hashContext } from '../storage/freshness';
44
import { loadCachedFlags } from '../storage/loadCachedFlags';
@@ -59,6 +59,24 @@ export default class FlagPersistence {
5959
return false;
6060
}
6161

62+
/**
63+
* Applies a changeset to the flag store.
64+
* - `'full'`: replaces all flags via {@link FlagUpdater.init}.
65+
* - `'partial'`: upserts individual flags via {@link FlagUpdater.upsert}.
66+
* - `'none'`: no flag changes, only persists cache to update freshness.
67+
*
68+
* Always persists to cache afterwards, which updates the freshness timestamp
69+
* even when no flags change (e.g., transfer-none).
70+
*/
71+
async applyChanges(
72+
context: Context,
73+
updates: { [key: string]: ItemDescriptor },
74+
type: internal.PayloadType,
75+
): Promise<void> {
76+
this._flagUpdater.applyChanges(context, updates, type);
77+
await this._storeCache(context);
78+
}
79+
6280
/**
6381
* Loads the flags from persistence for the provided context and gives those to the
6482
* {@link FlagUpdater} this {@link FlagPersistence} was constructed with.

packages/shared/sdk-client/src/flag-manager/FlagStore.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { internal } from '@launchdarkly/js-sdk-common';
2+
13
import { ItemDescriptor } from './ItemDescriptor';
24

35
/**
@@ -20,6 +22,14 @@ export default interface FlagStore {
2022
* Gets all the flags in the flag store.
2123
*/
2224
getAll(): { [key: string]: ItemDescriptor };
25+
26+
/**
27+
* Applies a changeset to the store without version checks.
28+
* - `'full'`: replaces all flags.
29+
* - `'partial'`: merges updates into existing flags.
30+
* - `'none'`: no-op.
31+
*/
32+
applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType): void;
2333
}
2434

2535
/**
@@ -49,5 +59,14 @@ export function createDefaultFlagStore(): FlagStore {
4959
getAll(): { [key: string]: ItemDescriptor } {
5060
return flags;
5161
},
62+
applyChanges(updates: { [key: string]: ItemDescriptor }, type: internal.PayloadType) {
63+
if (type === 'full') {
64+
this.init(updates);
65+
} else if (type === 'partial') {
66+
Object.entries(updates).forEach(([key, descriptor]) => {
67+
flags[key] = descriptor;
68+
});
69+
}
70+
},
5271
};
5372
}

packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Context, LDLogger } from '@launchdarkly/js-sdk-common';
1+
import { Context, internal, LDLogger } from '@launchdarkly/js-sdk-common';
22

33
import calculateChangedKeys from './calculateChangedKeys';
44
import FlagStore from './FlagStore';
@@ -60,6 +60,21 @@ export interface FlagUpdater {
6060
*/
6161
upsert(context: Context, key: string, item: ItemDescriptor): boolean;
6262

63+
/**
64+
* Applies a changeset directly to the flag store, bypassing version checks
65+
* and inactive-context guards. Used by the FDv2 data path where ordering
66+
* is handled at the protocol layer.
67+
*
68+
* - `'full'`: replaces all flags and emits change events for differences.
69+
* - `'partial'`: merges updates and emits change events for each key.
70+
* - `'none'`: no flag changes (caller handles freshness separately).
71+
*/
72+
applyChanges(
73+
context: Context,
74+
updates: { [key: string]: ItemDescriptor },
75+
type: internal.PayloadType,
76+
): void;
77+
6378
/**
6479
* Registers a callback to be called when the flags change.
6580
*
@@ -115,6 +130,29 @@ export default function createFlagUpdater(_flagStore: FlagStore, _logger: LDLogg
115130
this.init(context, newFlags);
116131
},
117132

133+
applyChanges(
134+
context: Context,
135+
updates: { [key: string]: ItemDescriptor },
136+
type: internal.PayloadType,
137+
): void {
138+
activeContext = context;
139+
const oldFlags = flagStore.getAll();
140+
flagStore.applyChanges(updates, type);
141+
142+
if (type === 'full') {
143+
const changed = calculateChangedKeys(oldFlags, updates);
144+
if (changed.length > 0) {
145+
this.handleFlagChanges(changed, 'init');
146+
}
147+
} else if (type === 'partial') {
148+
const keys = Object.keys(updates);
149+
if (keys.length > 0) {
150+
this.handleFlagChanges(keys, 'patch');
151+
}
152+
}
153+
// 'none' — no flag changes, caller handles freshness.
154+
},
155+
118156
upsert(context: Context, key: string, item: ItemDescriptor): boolean {
119157
if (activeContext?.canonicalKey !== context.canonicalKey) {
120158
logger.warn('Received an update for an inactive context.');

0 commit comments

Comments
 (0)