Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/browser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof makeMemoryStorage>;

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');
});
});
25 changes: 24 additions & 1 deletion packages/shared/sdk-client/src/flag-manager/FlagManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void>;

/**
* Register a flag change callback.
*/
Expand Down Expand Up @@ -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<void> {
return (await this._flagPersistencePromise).applyChanges(context, updates, type);
}

on(callback: FlagsChangeCallback): void {
this._flagUpdater.on(callback);
}
Expand Down
20 changes: 19 additions & 1 deletion packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
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.
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/sdk-client/src/flag-manager/FlagStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { internal } from '@launchdarkly/js-sdk-common';

import { ItemDescriptor } from './ItemDescriptor';

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
});
}
},
};
}
40 changes: 39 additions & 1 deletion packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.');
Expand Down
Loading