Skip to content

Commit 35de7b0

Browse files
committed
fix: migrate anonymous context namespace to general namespace
1 parent 6f8e7e3 commit 35de7b0

5 files changed

Lines changed: 147 additions & 13 deletions

File tree

packages/shared/sdk-client/__tests__/context/ensureKey.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,41 @@ describe('ensureKey', () => {
102102
const c = await ensureKey(context, mockPlatform);
103103
expect(c.key).toEqual('random1');
104104
});
105+
106+
it('should migrate anonymous key from legacy namespace', async () => {
107+
const stored: Record<string, string> = {
108+
LaunchDarkly_AnonymousKeys_org: 'migrated-key',
109+
};
110+
(mockPlatform.storage.get as jest.Mock).mockImplementation(
111+
(key: string) => stored[key] ?? null,
112+
);
113+
(mockPlatform.storage.set as jest.Mock).mockImplementation((key: string, value: string) => {
114+
stored[key] = value;
115+
});
116+
(mockPlatform.storage.clear as jest.Mock).mockImplementation((key: string) => {
117+
delete stored[key];
118+
});
119+
120+
const context: LDContext = { kind: 'org', anonymous: true };
121+
const c = await ensureKey(context, mockPlatform);
122+
123+
expect(c.key).toEqual('migrated-key');
124+
expect(mockPlatform.storage.set).toHaveBeenCalledWith(
125+
'LaunchDarkly_ContextKeys_org',
126+
'migrated-key',
127+
);
128+
expect(mockPlatform.storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org');
129+
});
130+
131+
it('should use new namespace key when it already exists', async () => {
132+
(mockPlatform.storage.get as jest.Mock).mockImplementation((key: string) =>
133+
key === 'LaunchDarkly_ContextKeys_org' ? 'new-ns-key' : undefined,
134+
);
135+
136+
const context: LDContext = { kind: 'org', anonymous: true };
137+
const c = await ensureKey(context, mockPlatform);
138+
139+
expect(c.key).toEqual('new-ns-key');
140+
expect(mockPlatform.storage.clear).not.toHaveBeenCalled();
141+
});
105142
});

packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,80 @@ describe('getOrGenerateKey', () => {
8989
expect(k).toEqual('test-org-key-2');
9090
});
9191
});
92+
93+
describe('legacy key migration', () => {
94+
it('migrates key from legacy location to new location', async () => {
95+
const stored: Record<string, string> = {
96+
LaunchDarkly_AnonymousKeys_org: 'existing-org-key',
97+
};
98+
(storage.get as jest.Mock).mockImplementation((key: string) => stored[key] ?? null);
99+
(storage.set as jest.Mock).mockImplementation((key: string, value: string) => {
100+
stored[key] = value;
101+
});
102+
(storage.clear as jest.Mock).mockImplementation((key: string) => {
103+
delete stored[key];
104+
});
105+
106+
const k = await getOrGenerateKey(
107+
'LaunchDarkly_ContextKeys_org',
108+
mockPlatform,
109+
'LaunchDarkly_AnonymousKeys_org',
110+
);
111+
112+
expect(k).toEqual('existing-org-key');
113+
expect(crypto.randomUUID).not.toHaveBeenCalled();
114+
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key');
115+
expect(storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org');
116+
});
117+
118+
it('does not clear legacy key when set silently fails', async () => {
119+
(storage.get as jest.Mock).mockImplementation((key: string) =>
120+
key === 'LaunchDarkly_AnonymousKeys_org' ? 'existing-org-key' : null,
121+
);
122+
// set is a no-op, simulating a silent storage failure
123+
(storage.set as jest.Mock).mockResolvedValue(undefined);
124+
125+
const k = await getOrGenerateKey(
126+
'LaunchDarkly_ContextKeys_org',
127+
mockPlatform,
128+
'LaunchDarkly_AnonymousKeys_org',
129+
);
130+
131+
expect(k).toEqual('existing-org-key');
132+
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key');
133+
expect(storage.clear).not.toHaveBeenCalled();
134+
});
135+
136+
it('does not check legacy key when new key already exists', async () => {
137+
(storage.get as jest.Mock).mockImplementation((key: string) =>
138+
key === 'LaunchDarkly_ContextKeys_org' ? 'new-org-key' : 'legacy-org-key',
139+
);
140+
141+
const k = await getOrGenerateKey(
142+
'LaunchDarkly_ContextKeys_org',
143+
mockPlatform,
144+
'LaunchDarkly_AnonymousKeys_org',
145+
);
146+
147+
expect(k).toEqual('new-org-key');
148+
expect(storage.get).toHaveBeenCalledTimes(1);
149+
expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org');
150+
expect(storage.clear).not.toHaveBeenCalled();
151+
});
152+
153+
it('generates new key when neither new nor legacy key exists', async () => {
154+
(storage.get as jest.Mock).mockResolvedValue(null);
155+
156+
const k = await getOrGenerateKey(
157+
'LaunchDarkly_ContextKeys_org',
158+
mockPlatform,
159+
'LaunchDarkly_AnonymousKeys_org',
160+
);
161+
162+
expect(k).toEqual('test-org-key-1');
163+
expect(crypto.randomUUID).toHaveBeenCalled();
164+
expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'test-org-key-1');
165+
expect(storage.clear).not.toHaveBeenCalled();
166+
});
167+
});
92168
});

packages/shared/sdk-client/src/context/ensureKey.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010

1111
import type { LDContext, LDContextStrict } from '../api/LDContext';
1212
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
13-
import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils';
13+
import {
14+
namespaceForAnonymousGeneratedContextKey,
15+
namespaceForGeneratedContextKey,
16+
} from '../storage/namespaceUtils';
1417

1518
const { isLegacyUser, isMultiKind, isSingleKind } = internal;
1619

@@ -31,10 +34,11 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf
3134
const { anonymous, key } = c;
3235

3336
if (anonymous && !key) {
34-
const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
37+
const storageKey = await namespaceForGeneratedContextKey(kind);
38+
const legacyStorageKey = await namespaceForAnonymousGeneratedContextKey(kind);
3539
// This mutates a cloned copy of the original context from ensureyKey so this is safe.
3640
// eslint-disable-next-line no-param-reassign
37-
c.key = await getOrGenerateKey(storageKey, platform);
41+
c.key = await getOrGenerateKey(storageKey, platform, legacyStorageKey);
3842
}
3943
};
4044

packages/shared/sdk-client/src/storage/getOrGenerateKey.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,34 @@ import { namespaceForGeneratedContextKey } from './namespaceUtils';
99
* @param storageKey keyed storage location where the generated key should live. See {@link namespaceForGeneratedContextKey}
1010
* for related exmaples of generating a storage key and usage.
1111
* @param platform crypto and storage implementations for necessary operations
12+
* @param legacyStorageKey optional legacy storage key to migrate from. If the key is not found
13+
* under {@link storageKey} but exists under this legacy key, it will be migrated to the new
14+
* location and the legacy key will be cleared.
1215
* @returns the generated key
1316
*/
14-
export const getOrGenerateKey = async (storageKey: string, { crypto, storage }: Platform) => {
17+
export const getOrGenerateKey = async (
18+
storageKey: string,
19+
{ crypto, storage }: Platform,
20+
legacyStorageKey?: string,
21+
) => {
1522
let generatedKey = await storage?.get(storageKey);
1623

17-
if (!generatedKey) {
18-
generatedKey = crypto.randomUUID();
19-
await storage?.set(storageKey, generatedKey);
24+
if (generatedKey == null) {
25+
if (legacyStorageKey) {
26+
generatedKey = await storage?.get(legacyStorageKey);
27+
if (generatedKey != null) {
28+
await storage?.set(storageKey, generatedKey);
29+
const verified = await storage?.get(storageKey);
30+
if (verified != null) {
31+
await storage?.clear(legacyStorageKey);
32+
}
33+
}
34+
}
35+
36+
if (generatedKey == null) {
37+
generatedKey = crypto.randomUUID();
38+
await storage?.set(storageKey, generatedKey);
39+
}
2040
}
2141

2242
return generatedKey;

packages/shared/sdk-client/src/storage/namespaceUtils.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,9 @@ export async function namespaceForEnvironment(crypto: Crypto, sdkKey: string): P
2828
}
2929

3030
/**
31-
* @deprecated prefer {@link namespaceForGeneratedContextKey}. At one time we only generated keys for
32-
* anonymous contexts and they were namespaced in LaunchDarkly_AnonymousKeys. Eventually we started
33-
* generating context keys for non-anonymous contexts such as for the Auto Environment Attributes
34-
* feature and those were namespaced in LaunchDarkly_ContextKeys. This function can be removed
35-
* when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the
36-
* LaunchDarkly_ContextKeys namespace.
31+
* @deprecated Used only for migration in ensureKey. Data stored under LaunchDarkly_AnonymousKeys
32+
* is now migrated to LaunchDarkly_ContextKeys on first access. This function can be removed once
33+
* all clients have had the chance to run the migration.
3734
*/
3835
export async function namespaceForAnonymousGeneratedContextKey(kind: string): Promise<string> {
3936
return concatNamespacesAndValues([

0 commit comments

Comments
 (0)