From 85c41f8886eeefd765f71065826524fc466b708a Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 5 Dec 2023 11:36:26 -0800 Subject: [PATCH 01/41] chore: Added Storage api. --- .../common/src/api/platform/Platform.ts | 7 +++++++ .../shared/common/src/api/platform/Storage.ts | 5 +++++ .../shared/sdk-client/src/LDClientImpl.ts | 19 +++++-------------- 3 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 packages/shared/common/src/api/platform/Storage.ts diff --git a/packages/shared/common/src/api/platform/Platform.ts b/packages/shared/common/src/api/platform/Platform.ts index 6c667dcaf..c7c7e67d5 100644 --- a/packages/shared/common/src/api/platform/Platform.ts +++ b/packages/shared/common/src/api/platform/Platform.ts @@ -3,6 +3,7 @@ import { Encoding } from './Encoding'; import { Filesystem } from './Filesystem'; import { Info } from './Info'; import { Requests } from './Requests'; +import { Storage } from './Storage'; export interface Platform { /** @@ -31,4 +32,10 @@ export interface Platform { * The interface for performing http/https requests. */ requests: Requests; + + /** + * The interface for session specific storage object. If the platform does not + * support local storage access, this may be undefined. + */ + storage?: Storage; } diff --git a/packages/shared/common/src/api/platform/Storage.ts b/packages/shared/common/src/api/platform/Storage.ts new file mode 100644 index 000000000..c67bcd10b --- /dev/null +++ b/packages/shared/common/src/api/platform/Storage.ts @@ -0,0 +1,5 @@ +export interface Storage { + get: (key: string) => Promise; + set: (key: string) => Promise; + clear: (key: string) => Promise; +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 03f0c1b81..a045e399d 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -191,6 +191,11 @@ export default class LDClientImpl implements LDClient { return Promise.reject(error); } + // TODO: check localStorage + const flagsInStorage = await this.platform.storage?.get(checkedContext.canonicalKey); + if (flagsInStorage) { + } + this.streamer?.close(); this.streamer = new internal.StreamingProcessor( this.sdkKey, @@ -217,10 +222,6 @@ export default class LDClientImpl implements LDClient { this.emitter.on(eventName, listener); } - setStreaming(value?: boolean): void { - // TODO: - } - track(key: string, data?: any, metricValue?: number): void { if (!this.context) { this.logger?.warn(ClientMessages.missingContextKeyNoEvent); @@ -360,14 +361,4 @@ export default class LDClientImpl implements LDClient { jsonVariationDetail(key: string, defaultValue: unknown): LDEvaluationDetailTyped { return this.variationDetail(key, defaultValue); } - - waitForInitialization(): Promise { - // TODO: - return Promise.resolve(undefined); - } - - waitUntilReady(): Promise { - // TODO: - return Promise.resolve(undefined); - } } From 6c7f556a22976f5b50cf51b38810ca3ebfd1cb2f Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 11:16:03 -0800 Subject: [PATCH 02/41] chore: replace connecting with initializing --- packages/sdk/react-native/src/provider/reactContext.ts | 2 +- packages/sdk/react-native/src/provider/setupListeners.ts | 4 ++-- packages/shared/sdk-client/src/LDClientImpl.ts | 2 +- packages/shared/sdk-client/src/api/LDEmitter.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk/react-native/src/provider/reactContext.ts b/packages/sdk/react-native/src/provider/reactContext.ts index 57f7a0e4b..98e43eb8b 100644 --- a/packages/sdk/react-native/src/provider/reactContext.ts +++ b/packages/sdk/react-native/src/provider/reactContext.ts @@ -6,7 +6,7 @@ export type ReactContext = { client: LDClient; context?: LDContext; dataSource: { - status?: 'connecting' | 'ready' | 'error'; + status?: 'initializing' | 'ready' | 'error'; error?: Error; }; }; diff --git a/packages/sdk/react-native/src/provider/setupListeners.ts b/packages/sdk/react-native/src/provider/setupListeners.ts index bde53b1c7..c3e561482 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.ts +++ b/packages/sdk/react-native/src/provider/setupListeners.ts @@ -9,8 +9,8 @@ const setupListeners = ( client: ReactNativeLDClient, setState: Dispatch>, ) => { - client.on('connecting', (c: LDContext) => { - setState({ client, context: c, dataSource: { status: 'connecting' } }); + client.on('initializing', (c: LDContext) => { + setState({ client, context: c, dataSource: { status: 'initializing' } }); }); client.on('ready', (c: LDContext) => { diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index a045e399d..59ad4c34d 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -208,7 +208,7 @@ export default class LDClientImpl implements LDClient { this.emitter.emit('error', context, e); }, ); - this.emitter.emit('connecting', context); + this.emitter.emit('initializing', context); this.streamer.start(); return this.createIdentifyPromise(); diff --git a/packages/shared/sdk-client/src/api/LDEmitter.ts b/packages/shared/sdk-client/src/api/LDEmitter.ts index 8c5350748..3b5f0f5fe 100644 --- a/packages/shared/sdk-client/src/api/LDEmitter.ts +++ b/packages/shared/sdk-client/src/api/LDEmitter.ts @@ -1,4 +1,4 @@ -export type EventName = 'connecting' | 'ready' | 'error' | 'change'; +export type EventName = 'initializing' | 'ready' | 'error' | 'change'; type CustomEventListeners = { original: Function; From 7bf332759282d03e8b611e33f30cb6842f6f0811 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 13:11:58 -0800 Subject: [PATCH 03/41] chore: added type LDFlagChangeset. Fix storage set signature. --- packages/shared/common/src/api/data/LDFlagChangeset.ts | 8 ++++++++ packages/shared/common/src/api/data/index.ts | 1 + packages/shared/common/src/api/platform/Storage.ts | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 packages/shared/common/src/api/data/LDFlagChangeset.ts diff --git a/packages/shared/common/src/api/data/LDFlagChangeset.ts b/packages/shared/common/src/api/data/LDFlagChangeset.ts new file mode 100644 index 000000000..306e44bf2 --- /dev/null +++ b/packages/shared/common/src/api/data/LDFlagChangeset.ts @@ -0,0 +1,8 @@ +import { LDFlagValue } from './LDFlagValue'; + +export interface LDFlagChangeset { + [key: string]: { + current: LDFlagValue; + previous: LDFlagValue; + }; +} diff --git a/packages/shared/common/src/api/data/index.ts b/packages/shared/common/src/api/data/index.ts index e990d7f62..a966f5341 100644 --- a/packages/shared/common/src/api/data/index.ts +++ b/packages/shared/common/src/api/data/index.ts @@ -2,3 +2,4 @@ export * from './LDEvaluationDetail'; export * from './LDEvaluationReason'; export * from './LDFlagSet'; export * from './LDFlagValue'; +export * from './LDFlagChangeset'; diff --git a/packages/shared/common/src/api/platform/Storage.ts b/packages/shared/common/src/api/platform/Storage.ts index c67bcd10b..4f7245faa 100644 --- a/packages/shared/common/src/api/platform/Storage.ts +++ b/packages/shared/common/src/api/platform/Storage.ts @@ -1,5 +1,5 @@ export interface Storage { get: (key: string) => Promise; - set: (key: string) => Promise; + set: (key: string, value: string) => Promise; clear: (key: string) => Promise; } From d95df72c64cd91df69632f514a0d9a07ccbed1ec Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 14:07:12 -0800 Subject: [PATCH 04/41] chore: added fast-deep-equal. removed unused api methods. --- packages/shared/sdk-client/package.json | 3 +- .../shared/sdk-client/src/api/LDClient.ts | 77 ------------------- 2 files changed, 2 insertions(+), 78 deletions(-) diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index 3ab99da49..b504480de 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -30,7 +30,8 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "^2.0.0" + "@launchdarkly/js-sdk-common": "^2.0.0", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@launchdarkly/private-js-mocks": "0.0.1", diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 1ad4373a6..24b304b8b 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -235,17 +235,6 @@ export interface LDClient { */ on(key: string, callback: (...args: any[]) => void, context?: any): void; - /** - * Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates. - * - * If this is true, the client will always attempt to maintain a streaming connection; if false, - * it never will. If you leave the value undefined (the default), the client will open a streaming - * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). - * - * This can also be set as the `streaming` property of {@link LDOptions}. - */ - setStreaming(value?: boolean): void; - /** * Determines the string variation of a feature flag. * @@ -335,70 +324,4 @@ export interface LDClient { * An {@link LDEvaluationDetail} object containing the value and explanation. */ variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail; - - /** - * Returns a Promise that tracks the client's initialization state. - * - * The Promise will be resolved if the client successfully initializes, or rejected if client - * initialization has irrevocably failed (for instance, if it detects that the SDK key is invalid). - * - * ``` - * // using Promise then() and catch() handlers - * client.waitForInitialization().then(() => { - * doSomethingWithSuccessfullyInitializedClient(); - * }).catch(err => { - * doSomethingForFailedStartup(err); - * }); - * - * // using async/await - * try { - * await client.waitForInitialization(); - * doSomethingWithSuccessfullyInitializedClient(); - * } catch (err) { - * doSomethingForFailedStartup(err); - * } - * ``` - * - * It is important that you handle the rejection case; otherwise it will become an unhandled Promise - * rejection, which is a serious error on some platforms. The Promise is not created unless you - * request it, so if you never call `waitForInitialization()` then you do not have to worry about - * unhandled rejections. - * - * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` - * indicates success, and `"failed"` indicates failure. - * - * @returns - * A Promise that will be resolved if the client initializes successfully, or rejected if it - * fails. - */ - waitForInitialization(): Promise; - - /** - * Returns a Promise that tracks the client's initialization state. - * - * The returned Promise will be resolved once the client has either successfully initialized - * or failed to initialize (e.g. due to an invalid environment key or a server error). It will - * never be rejected. - * - * ``` - * // using a Promise then() handler - * client.waitUntilReady().then(() => { - * doSomethingWithClient(); - * }); - * - * // using async/await - * await client.waitUntilReady(); - * doSomethingWithClient(); - * ``` - * - * If you want to distinguish between these success and failure conditions, use - * {@link waitForInitialization} instead. - * - * If you prefer to use event listeners ({@link on}) rather than Promises, you can listen on the - * client for a `"ready"` event, which will be fired in either case. - * - * @returns - * A Promise that will be resolved once the client is no longer trying to initialize. - */ - waitUntilReady(): Promise; } From e2f3cb4606f78af3133a5d347218511959d37712 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 14:07:57 -0800 Subject: [PATCH 05/41] chore: added skeleton to sync cached and put flags. --- .../shared/sdk-client/src/LDClientImpl.ts | 72 ++++++++++++++----- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 59ad4c34d..4b122ca32 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -1,8 +1,6 @@ -// temporarily allow unused vars for the duration of the migration +import fastDeepEqual from 'fast-deep-equal'; -/* eslint-disable @typescript-eslint/no-unused-vars */ import { - base64UrlEncode, ClientContext, clone, Context, @@ -25,7 +23,7 @@ import { LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; -import { Flags } from './evaluation/fetchFlags'; +import type { Flags } from './evaluation/fetchFlags'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; @@ -103,19 +101,46 @@ export default class LDClientImpl implements LDClient { return this.context ? clone(this.context) : undefined; } - private createStreamListeners(context: LDContext): Map { + private createStreamListeners( + context: LDContext, + canonicalKey: string, + initializedFromStorage: boolean, + ): Map { const listeners = new Map(); listeners.set('put', { deserializeData: JSON.parse, - processJson: (dataJson) => { - this.logger.debug('Initializing all data'); - this.context = context; - this.flags = {}; - Object.keys(dataJson).forEach((key) => { - this.flags[key] = dataJson[key]; - }); - this.emitter.emit('ready', context); + processJson: async (dataJson: Flags) => { + if (initializedFromStorage) { + this.logger.debug('Synchronizing all data'); + // TODO: sync and emit changes + + Object.entries(this.flags).forEach(([k, v]) => { + const flagFromPut = dataJson[k]; + + if (!flagFromPut) { + // flag deleted + // TODO: emit change + } else if (!fastDeepEqual(v, flagFromPut)) { + // flag changed + // TODO: emit change + } + }); + + Object.entries(dataJson).forEach(([k, _v]) => { + const flagFromStorage = this.flags[k]; + if (!flagFromStorage) { + // flag added + // TODO: emit change + } + }); + } else { + this.logger.debug('Initializing all data from stream'); + this.context = context; + this.flags = dataJson; + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); + this.emitter.emit('ready', context); + } }, }); @@ -152,7 +177,7 @@ export default class LDClientImpl implements LDClient { * @protected This function must be overridden in subclasses for streamer * to work. */ - protected createStreamUriPath(context: LDContext): string { + protected createStreamUriPath(_context: LDContext): string { throw new Error( 'createStreamUriPath not implemented. client sdks must implement createStreamUriPath for streamer to work', ); @@ -181,6 +206,11 @@ export default class LDClientImpl implements LDClient { }); } + private async getFlagsFromStorage(canonicalKey: string): Promise { + const f = await this.platform.storage?.get(canonicalKey); + return f ? JSON.parse(f) : undefined; + } + // TODO: implement secure mode async identify(context: LDContext, _hash?: string): Promise { const checkedContext = Context.fromLDContext(context); @@ -191,9 +221,14 @@ export default class LDClientImpl implements LDClient { return Promise.reject(error); } - // TODO: check localStorage - const flagsInStorage = await this.platform.storage?.get(checkedContext.canonicalKey); - if (flagsInStorage) { + this.emitter.emit('initializing', context); + + const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey); + if (flagsStorage) { + this.logger.debug('Initializing all data from storage'); + this.context = context; + this.flags = flagsStorage; + this.emitter.emit('ready', context); } this.streamer?.close(); @@ -201,14 +236,13 @@ export default class LDClientImpl implements LDClient { this.sdkKey, this.clientContext, this.createStreamUriPath(context), - this.createStreamListeners(context), + this.createStreamListeners(context, checkedContext.canonicalKey, !!flagsStorage), this.diagnosticsManager, (e) => { this.logger.error(e); this.emitter.emit('error', context, e); }, ); - this.emitter.emit('initializing', context); this.streamer.start(); return this.createIdentifyPromise(); From bc4e3de5ff58fddccc6e5acc7894e151c52d02e9 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 14:36:32 -0800 Subject: [PATCH 06/41] chore: implement storage and put sync logic --- .../common/src/api/data/LDFlagChangeset.ts | 4 ++-- .../shared/sdk-client/src/LDClientImpl.ts | 22 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/shared/common/src/api/data/LDFlagChangeset.ts b/packages/shared/common/src/api/data/LDFlagChangeset.ts index 306e44bf2..d0523a833 100644 --- a/packages/shared/common/src/api/data/LDFlagChangeset.ts +++ b/packages/shared/common/src/api/data/LDFlagChangeset.ts @@ -2,7 +2,7 @@ import { LDFlagValue } from './LDFlagValue'; export interface LDFlagChangeset { [key: string]: { - current: LDFlagValue; - previous: LDFlagValue; + current?: LDFlagValue; + previous?: LDFlagValue; }; } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 4b122ca32..ded47970d 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -9,6 +9,7 @@ import { LDContext, LDEvaluationDetail, LDEvaluationDetailTyped, + LDFlagChangeset, LDFlagSet, LDFlagValue, LDLogger, @@ -113,27 +114,32 @@ export default class LDClientImpl implements LDClient { processJson: async (dataJson: Flags) => { if (initializedFromStorage) { this.logger.debug('Synchronizing all data'); - // TODO: sync and emit changes + const changeset: LDFlagChangeset = {}; - Object.entries(this.flags).forEach(([k, v]) => { + Object.entries(this.flags).forEach(([k, f]) => { const flagFromPut = dataJson[k]; - if (!flagFromPut) { // flag deleted - // TODO: emit change - } else if (!fastDeepEqual(v, flagFromPut)) { + changeset[k] = { previous: f.value }; + } else if (!fastDeepEqual(f, flagFromPut)) { // flag changed - // TODO: emit change + changeset[k] = { previous: f.value, current: flagFromPut.value }; } }); - Object.entries(dataJson).forEach(([k, _v]) => { + Object.entries(dataJson).forEach(([k, v]) => { const flagFromStorage = this.flags[k]; if (!flagFromStorage) { // flag added - // TODO: emit change + changeset[k] = { current: v }; } }); + + this.flags = dataJson; + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); + if (Object.keys(changeset).length > 0) { + this.emitter.emit('change', changeset); + } } else { this.logger.debug('Initializing all data from stream'); this.context = context; From 63e6d3c7c880ea5a3fff77c09adccef283934f9f Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 14:48:35 -0800 Subject: [PATCH 07/41] fix: export platform.Storage. --- packages/shared/common/src/api/platform/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/common/src/api/platform/index.ts b/packages/shared/common/src/api/platform/index.ts index 0e488004e..ff46f3af4 100644 --- a/packages/shared/common/src/api/platform/index.ts +++ b/packages/shared/common/src/api/platform/index.ts @@ -5,3 +5,4 @@ export * from './Info'; export * from './Platform'; export * from './Requests'; export * from './EventSource'; +export * from './Storage'; From 682e3ea2b4d88875a82e5242baf6ca4f30726a54 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 11 Dec 2023 15:07:37 -0800 Subject: [PATCH 08/41] chore: implemented rn storage platform. --- packages/sdk/react-native/package.json | 3 +- .../react-native/src/ReactNativeLDClient.ts | 7 ++-- packages/sdk/react-native/src/platform.ts | 38 +++++++++++++++++-- .../shared/common/src/api/platform/Storage.ts | 2 +- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index a5349c85a..938d21671 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -45,7 +45,8 @@ "dependencies": { "@launchdarkly/js-client-sdk-common": "0.0.1", "base64-js": "^1.5.1", - "event-target-shim": "^6.0.2" + "event-target-shim": "^6.0.2", + "react-native": "^0.73.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 4a23619dd..5c7e6e52b 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -6,7 +6,7 @@ import { type LDOptions, } from '@launchdarkly/js-client-sdk-common'; -import platform from './platform'; +import createPlatform from './platform'; export default class ReactNativeLDClient extends LDClientImpl { constructor(sdkKey: string, options: LDOptions = {}) { @@ -17,10 +17,11 @@ export default class ReactNativeLDClient extends LDClientImpl { // eslint-disable-next-line no-console destination: console.log, }); - super(sdkKey, platform, { ...options, logger }); + + super(sdkKey, createPlatform(logger), { ...options, logger }); } override createStreamUriPath(context: LDContext) { - return `/meval/${base64UrlEncode(JSON.stringify(context), platform.encoding!)}`; + return `/meval/${base64UrlEncode(JSON.stringify(context), this.platform.encoding!)}`; } } diff --git a/packages/sdk/react-native/src/platform.ts b/packages/sdk/react-native/src/platform.ts index f209ce2dd..b05f5b3b2 100644 --- a/packages/sdk/react-native/src/platform.ts +++ b/packages/sdk/react-native/src/platform.ts @@ -1,4 +1,7 @@ /* eslint-disable max-classes-per-file */ +// @ts-ignore +import { AsyncStorage } from 'react-native'; + import type { Crypto, Encoding, @@ -8,12 +11,14 @@ import type { Hasher, Hmac, Info, + LDLogger, Options, Platform, PlatformData, Requests, Response, SdkData, + Storage, } from '@launchdarkly/js-client-sdk-common'; import { name, version } from '../package.json'; @@ -68,11 +73,38 @@ class PlatformCrypto implements Crypto { return uuidv4(); } } -const platform: Platform = { + +class PlatformStorage implements Storage { + constructor(private readonly logger: LDLogger) {} + async clear(key: string): Promise { + await AsyncStorage.clear(key); + } + + async get(key: string): Promise { + try { + const value = await AsyncStorage.getItem(key); + return value ?? null; + } catch (error) { + this.logger.debug(`Error getting AsyncStorage key: ${key}, error: ${error}`); + return null; + } + } + + async set(key: string, value: string): Promise { + try { + await AsyncStorage.setItem(key, value); + } catch (error) { + this.logger.debug(`Error saving AsyncStorage key: ${key}, value: ${value}, error: ${error}`); + } + } +} + +const createPlatform = (logger: LDLogger): Platform => ({ crypto: new PlatformCrypto(), info: new PlatformInfo(), requests: new PlatformRequests(), encoding: new PlatformEncoding(), -}; + storage: new PlatformStorage(logger), +}); -export default platform; +export default createPlatform; diff --git a/packages/shared/common/src/api/platform/Storage.ts b/packages/shared/common/src/api/platform/Storage.ts index 4f7245faa..ec4379230 100644 --- a/packages/shared/common/src/api/platform/Storage.ts +++ b/packages/shared/common/src/api/platform/Storage.ts @@ -1,5 +1,5 @@ export interface Storage { - get: (key: string) => Promise; + get: (key: string) => Promise; set: (key: string, value: string) => Promise; clear: (key: string) => Promise; } From 269b02ff781067bcb806bee9f0150ee59c1d02ad Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Tue, 12 Dec 2023 17:36:14 -0800 Subject: [PATCH 09/41] chore: add local copy of fast-deep-equal. add storage tests. --- .../common/src/utils/fast-deep-equal/LICENSE | 21 ++++ .../common/src/utils/fast-deep-equal/index.ts | 78 +++++++++++++ packages/shared/common/src/utils/index.ts | 2 + packages/shared/mocks/src/platform.ts | 9 +- packages/shared/sdk-client/package.json | 3 +- .../src/LDClientImpl.storage.test.ts | 104 ++++++++++++++++++ .../sdk-client/src/LDClientImpl.test.ts | 2 +- .../shared/sdk-client/src/LDClientImpl.ts | 10 +- 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 packages/shared/common/src/utils/fast-deep-equal/LICENSE create mode 100644 packages/shared/common/src/utils/fast-deep-equal/index.ts create mode 100644 packages/shared/sdk-client/src/LDClientImpl.storage.test.ts diff --git a/packages/shared/common/src/utils/fast-deep-equal/LICENSE b/packages/shared/common/src/utils/fast-deep-equal/LICENSE new file mode 100644 index 000000000..7f1543566 --- /dev/null +++ b/packages/shared/common/src/utils/fast-deep-equal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/shared/common/src/utils/fast-deep-equal/index.ts b/packages/shared/common/src/utils/fast-deep-equal/index.ts new file mode 100644 index 000000000..4025edcad --- /dev/null +++ b/packages/shared/common/src/utils/fast-deep-equal/index.ts @@ -0,0 +1,78 @@ +/* eslint-disable */ +// Ripped from https://github.com/epoberezkin/fast-deep-fastDeepEqual + +// {{? it.es6 }} +// var envHasBigInt64Array = typeof BigInt64Array !== 'undefined'; +// {{?}} + +export default function fastDeepEqual(a: any, b: any) { + if (a === b) return true; + + if (a && b && typeof a == 'object' && typeof b == 'object') { + if (a.constructor !== b.constructor) return false; + + var length, i, keys; + if (Array.isArray(a)) { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0; ) if (!fastDeepEqual(a[i], b[i])) return false; + return true; + } + + // {{? it.es6 }} + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (i of a.entries()) if (!b.has(i[0])) return false; + for (i of a.entries()) if (!fastDeepEqual(i[1], b.get(i[0]))) return false; + return true; + } + + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false; + for (i of a.entries()) if (!b.has(i[0])) return false; + return true; + } + + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + // @ts-ignore + length = a.length; + // @ts-ignore + if (length != b.length) return false; + for (i = length; i-- !== 0; ) { + // @ts-ignore + if (a[i] !== b[i]) return false; + } + return true; + } + // {{?}} + + if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; + if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); + if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) return false; + + for (i = length; i-- !== 0; ) + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; + + for (i = length; i-- !== 0; ) { + var key = keys[i]; + // {{? it.react }} + // if (key === '_owner' && a.$$typeof) { + // // React-specific: avoid traversing React elements' _owner. + // // _owner contains circular references + // // and is not needed when comparing the actual elements (and not their owners) + // continue; + // } + // {{?}} + if (!fastDeepEqual(a[key], b[key])) return false; + } + + return true; + } + + // true if both NaN, false otherwise + return a !== a && b !== b; +} diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index 693e3afb7..7424ac658 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,5 +1,6 @@ import clone from './clone'; import { secondsToMillis } from './date'; +import fastDeepEqual from './fast-deep-equal'; import { base64UrlEncode, defaultHeaders, httpErrorMessage, LDHeaders, shouldRetry } from './http'; import noop from './noop'; import sleep from './sleep'; @@ -9,6 +10,7 @@ export { base64UrlEncode, clone, defaultHeaders, + fastDeepEqual, httpErrorMessage, noop, LDHeaders, diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/mocks/src/platform.ts index 7b11b5c71..ed08a80c9 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/mocks/src/platform.ts @@ -1,4 +1,4 @@ -import type { Encoding, Info, Platform, PlatformData, Requests, SdkData } from '@common'; +import type { Encoding, Info, Platform, PlatformData, Requests, SdkData, Storage } from '@common'; import { crypto } from './hasher'; @@ -36,11 +36,18 @@ const requests: Requests = { createEventSource: jest.fn(), }; +const storage: Storage = { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), +}; + const basicPlatform: Platform = { encoding, info, crypto, requests, + storage, }; export default basicPlatform; diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index b504480de..3ab99da49 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -30,8 +30,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "^2.0.0", - "fast-deep-equal": "^3.1.3" + "@launchdarkly/js-sdk-common": "^2.0.0" }, "devDependencies": { "@launchdarkly/private-js-mocks": "0.0.1", diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts new file mode 100644 index 000000000..e9866c7b3 --- /dev/null +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -0,0 +1,104 @@ +import { clone, type LDContext, LDFlagChangeset } from '@launchdarkly/js-sdk-common'; +import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; + +import LDEmitter from './api/LDEmitter'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import LDClientImpl from './LDClientImpl'; + +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + const { MockStreamingProcessor: mockStreamer } = jest.requireActual( + '@launchdarkly/private-js-mocks', + ); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + StreamingProcessor: mockStreamer, + }, + }, + }; +}); +describe('sdk-client storage', () => { + const testSdkKey = 'test-sdk-key'; + const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; + let ldc: LDClientImpl; + let emitter: LDEmitter; + + beforeEach(() => { + jest.useFakeTimers(); + setupMockStreamingProcessor(false, mockResponseJson); + basicPlatform.storage.get.mockImplementation(() => JSON.stringify(mockResponseJson)); + jest + .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') + .mockReturnValue('/stream/path'); + // jest.spyOn(LDEmitter.prototype as any, 'emit'); + + ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); + + // @ts-ignore + emitter = ldc.emitter; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('initialize from storage succeeds without streamer', async () => { + // make sure streamer errors + setupMockStreamingProcessor(true); + + try { + await ldc.identify(context); + } catch (e) {} + const all = ldc.allFlags(); + + expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); + // expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); + // expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); + expect(all).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('sync deleted', async () => { + const putResponse = clone(mockResponseJson); + delete putResponse['dev-test-flag']; + setupMockStreamingProcessor(false, putResponse); + + let changes: LDFlagChangeset; + ldc.on('change', (_context: LDContext, changeset: LDFlagChangeset) => { + changes = changeset; + }); + + try { + await ldc.identify(context); + } catch (e) {} + jest.runAllTicks(); + const all = ldc.allFlags(); + + expect(all).not.toContain('dev-test-flag'); + expect(basicPlatform.storage.set).toHaveBeenCalledWith( + 'org:Testy Pizza', + JSON.stringify(putResponse), + ); + + // TODO: test deleted changeset from emitter + // @ts-ignore + // expect(changes).toEqual({}); + + // expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); + // expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); + // expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + // 'dev-test-flag': { previous: true }, + // }); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index 509b2438a..d8abdf16c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -30,7 +30,7 @@ describe('sdk-client object', () => { beforeEach(() => { setupMockStreamingProcessor(false, mockResponseJson); - ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger }); + ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index ded47970d..338f8137b 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -1,9 +1,8 @@ -import fastDeepEqual from 'fast-deep-equal'; - import { ClientContext, clone, Context, + fastDeepEqual, internal, LDClientError, LDContext, @@ -138,7 +137,7 @@ export default class LDClientImpl implements LDClient { this.flags = dataJson; await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); if (Object.keys(changeset).length > 0) { - this.emitter.emit('change', changeset); + this.emitter.emit('change', context, changeset); } } else { this.logger.debug('Initializing all data from stream'); @@ -189,7 +188,7 @@ export default class LDClientImpl implements LDClient { ); } - private createIdentifyPromise() { + private createPromiseWithListeners() { return new Promise((resolve, reject) => { if (this.identifyReadyListener) { this.emitter.off('ready', this.identifyReadyListener); @@ -227,6 +226,7 @@ export default class LDClientImpl implements LDClient { return Promise.reject(error); } + const p = this.createPromiseWithListeners(); this.emitter.emit('initializing', context); const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey); @@ -251,7 +251,7 @@ export default class LDClientImpl implements LDClient { ); this.streamer.start(); - return this.createIdentifyPromise(); + return p; } off(eventName: EventName, listener?: Function): void { From 849337472479fb265c6fb821aa7846a4a997c25b Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 13 Dec 2023 14:47:14 -0800 Subject: [PATCH 10/41] fix: fixed hanging unit tests due to emits not being awaited. added more tests for synchronization. --- .../src/LDClientImpl.storage.test.ts | 156 +++++++++++++----- .../shared/sdk-client/src/LDClientImpl.ts | 2 +- 2 files changed, 119 insertions(+), 39 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index e9866c7b3..d005540e5 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -20,25 +20,60 @@ jest.mock('@launchdarkly/js-sdk-common', () => { }, }; }); -describe('sdk-client storage', () => { - const testSdkKey = 'test-sdk-key'; - const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; - let ldc: LDClientImpl; - let emitter: LDEmitter; +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +let ldc: LDClientImpl; +let emitter: LDEmitter; + +// Promisify on.change listener so we can await it in tests. +const onChangePromise = () => + new Promise((res) => { + ldc.on('change', (_context: LDContext, changeset: LDFlagChangeset) => { + res(changeset); + }); + }); + +// Common setup code for all tests +// 1. Sets up streamer +// 2. Sets up the change listener +// 3. Runs identify +// 4. Get all flags +const identifyGetAllFlags = async ( + putResponse: any = mockResponseJson, + shouldError: boolean = false, +) => { + setupMockStreamingProcessor(shouldError, putResponse); + const changePromise = onChangePromise(); + + try { + await ldc.identify(context); + } catch (e) { + /* empty */ + } + jest.runAllTicks(); + + // if streamer errors, don't wait for 'change' because it will not be sent. + if (!shouldError) { + await changePromise; + } + + return ldc.allFlags(); +}; + +describe('sdk-client storage', () => { beforeEach(() => { jest.useFakeTimers(); - setupMockStreamingProcessor(false, mockResponseJson); basicPlatform.storage.get.mockImplementation(() => JSON.stringify(mockResponseJson)); jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); - // jest.spyOn(LDEmitter.prototype as any, 'emit'); ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); // @ts-ignore emitter = ldc.emitter; + jest.spyOn(emitter as LDEmitter, 'emit'); }); afterEach(() => { @@ -47,17 +82,21 @@ describe('sdk-client storage', () => { test('initialize from storage succeeds without streamer', async () => { // make sure streamer errors - setupMockStreamingProcessor(true); - - try { - await ldc.identify(context); - } catch (e) {} - const all = ldc.allFlags(); + const allFlags = await identifyGetAllFlags(mockResponseJson, true); expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); - // expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); - // expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); - expect(all).toEqual({ + + // 'change' should not have been emitted + expect(emitter.emit).toHaveBeenCalledTimes(3); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); + expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); + expect(emitter.emit).toHaveBeenNthCalledWith( + 3, + 'error', + context, + expect.objectContaining({ message: 'test-error' }), + ); + expect(allFlags).toEqual({ 'dev-test-flag': true, 'easter-i-tunes-special': false, 'easter-specials': 'no specials', @@ -69,36 +108,77 @@ describe('sdk-client storage', () => { }); }); - test('sync deleted', async () => { + test('syncing storage when a flag is deleted', async () => { const putResponse = clone(mockResponseJson); delete putResponse['dev-test-flag']; - setupMockStreamingProcessor(false, putResponse); + const allFlags = await identifyGetAllFlags(putResponse); - let changes: LDFlagChangeset; - ldc.on('change', (_context: LDContext, changeset: LDFlagChangeset) => { - changes = changeset; - }); - - try { - await ldc.identify(context); - } catch (e) {} - jest.runAllTicks(); - const all = ldc.allFlags(); - - expect(all).not.toContain('dev-test-flag'); + expect(allFlags).not.toHaveProperty('dev-test-flag'); expect(basicPlatform.storage.set).toHaveBeenCalledWith( 'org:Testy Pizza', JSON.stringify(putResponse), ); + expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); + expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { previous: true }, + }); + }); - // TODO: test deleted changeset from emitter - // @ts-ignore - // expect(changes).toEqual({}); + test('syncing storage when a flag is added', async () => { + const putResponse = clone(mockResponseJson); + const newFlag = { + version: 1, + flagVersion: 2, + value: false, + variation: 1, + trackEvents: false, + }; + putResponse['another-dev-test-flag'] = newFlag; + const allFlags = await identifyGetAllFlags(putResponse); + + expect(allFlags).toMatchObject({ 'another-dev-test-flag': false }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'another-dev-test-flag': { current: newFlag }, + }); + }); + + test('syncing storage when a flag is updated', async () => { + const putResponse = clone(mockResponseJson); + putResponse['dev-test-flag'].version = '999'; + putResponse['dev-test-flag'].value = false; + const allFlags = await identifyGetAllFlags(putResponse); + + expect(allFlags).toMatchObject({ 'dev-test-flag': false }); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { + previous: true, + current: putResponse['dev-test-flag'], + }, + }); + }); + + test('syncing storage on multiple flag operations', async () => { + const putResponse = clone(mockResponseJson); + const newFlag = clone(putResponse['dev-test-flag']); - // expect(emitter.emit).toHaveBeenNthCalledWith(1, 'initializing', context); - // expect(emitter.emit).toHaveBeenNthCalledWith(2, 'ready', context); - // expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - // 'dev-test-flag': { previous: true }, - // }); + // flag updated, added and deleted + putResponse['dev-test-flag'].value = false; + putResponse['another-dev-test-flag'] = newFlag; + delete putResponse['moonshot-demo']; + const allFlags = await identifyGetAllFlags(putResponse); + + expect(allFlags).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); + expect(allFlags).not.toHaveProperty('moonshot-demo'); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { + previous: true, + current: putResponse['dev-test-flag'], + }, + 'another-dev-test-flag': { current: newFlag }, + 'moonshot-demo': { previous: true }, + }); }); + + // TODO: add tests for patch and delete listeners }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 338f8137b..23688e785 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -122,7 +122,7 @@ export default class LDClientImpl implements LDClient { changeset[k] = { previous: f.value }; } else if (!fastDeepEqual(f, flagFromPut)) { // flag changed - changeset[k] = { previous: f.value, current: flagFromPut.value }; + changeset[k] = { previous: f.value, current: flagFromPut }; } }); From 470a3fc33c45d6090dd5f4e038da461f6ca50fa4 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Wed, 13 Dec 2023 15:04:07 -0800 Subject: [PATCH 11/41] chore: added community rn async storage package. --- packages/sdk/react-native/link-dev.sh | 12 ++++++++---- packages/sdk/react-native/package.json | 4 ++-- packages/sdk/react-native/src/platform.ts | 6 +++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/sdk/react-native/link-dev.sh b/packages/sdk/react-native/link-dev.sh index da6e062ed..8b6c91f56 100755 --- a/packages/sdk/react-native/link-dev.sh +++ b/packages/sdk/react-native/link-dev.sh @@ -8,10 +8,12 @@ declare -a examples=(example) for example in "${examples[@]}" do echo "===== Linking to $example" - MODULES_DIR=$example/node_modules - SDK_DIR=$MODULES_DIR/@launchdarkly/react-native-client-sdk - COMMON_DIR="$SDK_DIR"/node_modules/@launchdarkly/js-sdk-common - CLIENT_COMMON_DIR="$SDK_DIR"/node_modules/@launchdarkly/js-client-sdk-common + MODULES_DIR="$example"/node_modules + SDK_DIR="$MODULES_DIR"/@launchdarkly/react-native-client-sdk + SDK_DIR_MODULES="$SDK_DIR"/node_modules + SDK_LD_DIR="$SDK_DIR_MODULES"/@launchdarkly + COMMON_DIR="$SDK_LD_DIR"/js-sdk-common + CLIENT_COMMON_DIR="$SDK_LD_DIR"/js-client-sdk-common mkdir -p "$MODULES_DIR" rm -rf "$SDK_DIR" @@ -22,6 +24,7 @@ do rsync -aq src "$SDK_DIR" rsync -aq package.json "$SDK_DIR" rsync -aq LICENSE "$SDK_DIR" + rsync -aq node_modules/@react-native-async-storage "$SDK_DIR"/node_modules rsync -aq node_modules/base64-js "$SDK_DIR"/node_modules rsync -aq node_modules/event-target-shim "$SDK_DIR"/node_modules @@ -33,4 +36,5 @@ do rsync -aq ../../shared/sdk-client/dist "$CLIENT_COMMON_DIR" rsync -aq ../../shared/sdk-client/src "$CLIENT_COMMON_DIR" rsync -aq ../../shared/sdk-client/package.json "$CLIENT_COMMON_DIR" + done diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 938d21671..6eba9cb35 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -44,9 +44,9 @@ }, "dependencies": { "@launchdarkly/js-client-sdk-common": "0.0.1", + "@react-native-async-storage/async-storage": "^1.21.0", "base64-js": "^1.5.1", - "event-target-shim": "^6.0.2", - "react-native": "^0.73.0" + "event-target-shim": "^6.0.2" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/react-native/src/platform.ts b/packages/sdk/react-native/src/platform.ts index b05f5b3b2..f362a90fe 100644 --- a/packages/sdk/react-native/src/platform.ts +++ b/packages/sdk/react-native/src/platform.ts @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file */ -// @ts-ignore -import { AsyncStorage } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +// @ts-ignore import type { Crypto, Encoding, @@ -77,7 +77,7 @@ class PlatformCrypto implements Crypto { class PlatformStorage implements Storage { constructor(private readonly logger: LDLogger) {} async clear(key: string): Promise { - await AsyncStorage.clear(key); + await AsyncStorage.removeItem(key); } async get(key: string): Promise { From 460507a71a4d6e2257ebe9d546f1655a6b5928cb Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 14 Dec 2023 10:38:58 -0800 Subject: [PATCH 12/41] fix: added internalOptions to configure events and diagnostic endpoints for rn. --- packages/sdk/react-native/example/package.json | 1 + packages/sdk/react-native/src/ReactNativeLDClient.ts | 10 ++++++++-- packages/shared/sdk-client/src/LDClientImpl.ts | 3 ++- .../sdk-client/src/configuration/Configuration.ts | 12 ++++++++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/sdk/react-native/example/package.json b/packages/sdk/react-native/example/package.json index 113904380..b0f47fee4 100644 --- a/packages/sdk/react-native/example/package.json +++ b/packages/sdk/react-native/example/package.json @@ -17,6 +17,7 @@ "clean": "expo prebuild --clean && yarn cache clean && rm -rf node_modules && rm -rf .expo" }, "dependencies": { + "@react-native-async-storage/async-storage": "1.18.2", "expo": "~49.0.16", "expo-splash-screen": "~0.20.5", "expo-status-bar": "~1.7.1", diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 5c7e6e52b..d642c5acf 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -1,6 +1,7 @@ import { base64UrlEncode, BasicLogger, + internal, LDClientImpl, type LDContext, type LDOptions, @@ -13,12 +14,17 @@ export default class ReactNativeLDClient extends LDClientImpl { const logger = options.logger ?? new BasicLogger({ - level: 'info', + level: 'debug', // eslint-disable-next-line no-console destination: console.log, }); - super(sdkKey, createPlatform(logger), { ...options, logger }); + const internalOptions: internal.LDInternalOptions = { + analyticsEventPath: `/mobile`, + diagnosticEventPath: `/mobile/events/diagnostic`, + }; + + super(sdkKey, createPlatform(logger), { ...options, logger }, internalOptions); } override createStreamUriPath(context: LDContext) { diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 23688e785..ff2f95048 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -53,6 +53,7 @@ export default class LDClientImpl implements LDClient { public readonly sdkKey: string, public readonly platform: Platform, options: LDOptions, + internalOptions: internal.LDInternalOptions, ) { if (!sdkKey) { throw new Error('You must configure the client with a client-side SDK key'); @@ -62,7 +63,7 @@ export default class LDClientImpl implements LDClient { throw new Error('Platform must implement Encoding because btoa is required.'); } - this.config = new Configuration(options); + this.config = new Configuration(options, internalOptions); this.clientContext = new ClientContext(sdkKey, this.config, platform); this.logger = this.config.logger; this.diagnosticsManager = createDiagnosticsManager(sdkKey, this.config, platform); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 27d10e8f5..861e69d22 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -1,6 +1,7 @@ import { ApplicationTags, createSafeLogger, + internal, LDFlagSet, NumberWithMinimum, OptionMessages, @@ -51,11 +52,18 @@ export default class Configuration { // Allow indexing Configuration by a string [index: string]: any; - constructor(pristineOptions: LDOptions = {}) { + constructor(pristineOptions: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { const errors = this.validateTypesAndNames(pristineOptions); errors.forEach((e: string) => this.logger.warn(e)); - this.serviceEndpoints = new ServiceEndpoints(this.streamUri, this.baseUri, this.eventsUri); + this.serviceEndpoints = new ServiceEndpoints( + this.streamUri, + this.baseUri, + this.eventsUri, + internalOptions.analyticsEventPath, // TODO: rn set to /mobile + internalOptions.diagnosticEventPath, // TODO: rn set to /mobile/events/diagnostic + internalOptions.includeAuthorizationHeader, + ); this.tags = new ApplicationTags({ application: this.application, logger: this.logger }); } From 6a62c6a489e98fc7bf4cd0e0665fd224f0476a4d Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 14 Dec 2023 10:45:46 -0800 Subject: [PATCH 13/41] fix: make internalOptions optional and fixed unit tests. removed redundant todos. --- packages/shared/sdk-client/src/LDClientImpl.ts | 2 +- packages/shared/sdk-client/src/configuration/Configuration.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index ff2f95048..172c5147a 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -53,7 +53,7 @@ export default class LDClientImpl implements LDClient { public readonly sdkKey: string, public readonly platform: Platform, options: LDOptions, - internalOptions: internal.LDInternalOptions, + internalOptions?: internal.LDInternalOptions, ) { if (!sdkKey) { throw new Error('You must configure the client with a client-side SDK key'); diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 861e69d22..52fe58799 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -60,8 +60,8 @@ export default class Configuration { this.streamUri, this.baseUri, this.eventsUri, - internalOptions.analyticsEventPath, // TODO: rn set to /mobile - internalOptions.diagnosticEventPath, // TODO: rn set to /mobile/events/diagnostic + internalOptions.analyticsEventPath, + internalOptions.diagnosticEventPath, internalOptions.includeAuthorizationHeader, ); this.tags = new ApplicationTags({ application: this.application, logger: this.logger }); From ed4bb3ac39ff9d25769c26c43aefbae0a8d54e52 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 14 Dec 2023 10:48:33 -0800 Subject: [PATCH 14/41] chore: add unit test for internal options. --- .../sdk/react-native/src/ReactNativeLDClient.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts b/packages/sdk/react-native/src/ReactNativeLDClient.test.ts index 1f0ff848c..1bbcb4f0a 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.test.ts @@ -9,8 +9,16 @@ describe('ReactNativeLDClient', () => { ldc = new ReactNativeLDClient('mob-test', { sendEvents: false }); }); - test('constructor', () => { + test('constructing a new client', () => { expect(ldc.sdkKey).toEqual('mob-test'); + expect(ldc.config.serviceEndpoints).toEqual({ + analyticsEventPath: '/mobile', + diagnosticEventPath: '/mobile/events/diagnostic', + events: 'https://events.launchdarkly.com', + includeAuthorizationHeader: true, + polling: 'https://sdk.launchdarkly.com', + streaming: 'https://clientstream.launchdarkly.com', + }); }); test('createStreamUriPath', () => { From 9f5eb0320d9fe5888420c8604bcbbacfaf8a9736 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 14 Dec 2023 18:03:34 -0800 Subject: [PATCH 15/41] fix: fix bug where change event emits wrong flag values. added version check for patch and delete. added test for inExperiment. --- packages/shared/common/src/utils/clone.ts | 4 +- .../src/LDClientImpl.storage.test.ts | 55 ++++++++++++++----- .../sdk-client/src/LDClientImpl.test.ts | 1 - .../shared/sdk-client/src/LDClientImpl.ts | 50 ++++++++++++----- .../shared/sdk-client/src/api/LDClient.ts | 2 +- .../sdk-client/src/evaluation/fetchFlags.ts | 10 +++- 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/packages/shared/common/src/utils/clone.ts b/packages/shared/common/src/utils/clone.ts index 5ae8a7abe..14e19d8e3 100644 --- a/packages/shared/common/src/utils/clone.ts +++ b/packages/shared/common/src/utils/clone.ts @@ -1,3 +1,3 @@ -export default function clone(obj: any) { - return JSON.parse(JSON.stringify(obj)); +export default function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)) as T; } diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index d005540e5..86d1f9b52 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -2,6 +2,7 @@ import { clone, type LDContext, LDFlagChangeset } from '@launchdarkly/js-sdk-com import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; import LDEmitter from './api/LDEmitter'; +import type { Flag, Flags } from './evaluation/fetchFlags'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; @@ -21,6 +22,7 @@ jest.mock('@launchdarkly/js-sdk-common', () => { }; }); +const defaultPutResponse = mockResponseJson as Flags; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; let ldc: LDClientImpl; @@ -40,7 +42,7 @@ const onChangePromise = () => // 3. Runs identify // 4. Get all flags const identifyGetAllFlags = async ( - putResponse: any = mockResponseJson, + putResponse = defaultPutResponse, shouldError: boolean = false, ) => { setupMockStreamingProcessor(shouldError, putResponse); @@ -64,7 +66,7 @@ const identifyGetAllFlags = async ( describe('sdk-client storage', () => { beforeEach(() => { jest.useFakeTimers(); - basicPlatform.storage.get.mockImplementation(() => JSON.stringify(mockResponseJson)); + basicPlatform.storage.get.mockImplementation(() => JSON.stringify(defaultPutResponse)); jest .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') .mockReturnValue('/stream/path'); @@ -82,7 +84,7 @@ describe('sdk-client storage', () => { test('initialize from storage succeeds without streamer', async () => { // make sure streamer errors - const allFlags = await identifyGetAllFlags(mockResponseJson, true); + const allFlags = await identifyGetAllFlags(defaultPutResponse, true); expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); @@ -109,7 +111,7 @@ describe('sdk-client storage', () => { }); test('syncing storage when a flag is deleted', async () => { - const putResponse = clone(mockResponseJson); + const putResponse = clone(defaultPutResponse); delete putResponse['dev-test-flag']; const allFlags = await identifyGetAllFlags(putResponse); @@ -126,7 +128,7 @@ describe('sdk-client storage', () => { }); test('syncing storage when a flag is added', async () => { - const putResponse = clone(mockResponseJson); + const putResponse = clone(defaultPutResponse); const newFlag = { version: 1, flagVersion: 2, @@ -138,14 +140,18 @@ describe('sdk-client storage', () => { const allFlags = await identifyGetAllFlags(putResponse); expect(allFlags).toMatchObject({ 'another-dev-test-flag': false }); + expect(basicPlatform.storage.set).toHaveBeenCalledWith( + 'org:Testy Pizza', + JSON.stringify(putResponse), + ); expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { - 'another-dev-test-flag': { current: newFlag }, + 'another-dev-test-flag': { current: newFlag.value }, }); }); test('syncing storage when a flag is updated', async () => { - const putResponse = clone(mockResponseJson); - putResponse['dev-test-flag'].version = '999'; + const putResponse = clone(defaultPutResponse); + putResponse['dev-test-flag'].version = 999; putResponse['dev-test-flag'].value = false; const allFlags = await identifyGetAllFlags(putResponse); @@ -153,14 +159,14 @@ describe('sdk-client storage', () => { expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { 'dev-test-flag': { previous: true, - current: putResponse['dev-test-flag'], + current: putResponse['dev-test-flag'].value, }, }); }); test('syncing storage on multiple flag operations', async () => { - const putResponse = clone(mockResponseJson); - const newFlag = clone(putResponse['dev-test-flag']); + const putResponse = clone(defaultPutResponse); + const newFlag = clone(putResponse['dev-test-flag']); // flag updated, added and deleted putResponse['dev-test-flag'].value = false; @@ -173,12 +179,35 @@ describe('sdk-client storage', () => { expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { 'dev-test-flag': { previous: true, - current: putResponse['dev-test-flag'], + current: putResponse['dev-test-flag'].value, }, - 'another-dev-test-flag': { current: newFlag }, + 'another-dev-test-flag': { current: newFlag.value }, 'moonshot-demo': { previous: true }, }); }); + test('an update to inExperiment should emit change event', async () => { + const putResponse = clone(defaultPutResponse); + putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; + + const allFlags = await identifyGetAllFlags(putResponse); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + + expect(allFlags).toMatchObject({ 'dev-test-flag': true }); + expect(flagsInStorage['dev-test-flag'].reason).toEqual({ + kind: 'RULE_MATCH', + inExperiment: true, + }); + + // both previous and current are true but inExperiment has changed + // so a change event should be emitted + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { + previous: true, + current: true, + }, + }); + }); + // TODO: add tests for patch and delete listeners }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index d8abdf16c..39b89e132 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -9,7 +9,6 @@ import { import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; -jest.mock('./evaluation/fetchFlags'); jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); return { diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 172c5147a..9e2a905a8 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -23,7 +23,7 @@ import { LDClient, type LDOptions } from './api'; import LDEmitter, { EventName } from './api/LDEmitter'; import Configuration from './configuration'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; -import type { Flags } from './evaluation/fetchFlags'; +import type { DeleteFlag, Flags, PatchFlag } from './evaluation/fetchFlags'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; @@ -98,8 +98,8 @@ export default class LDClientImpl implements LDClient { return { result: true }; } - getContext(): LDContext { - return this.context ? clone(this.context) : undefined; + getContext(): LDContext | undefined { + return this.context ? clone(this.context) : undefined; } private createStreamListeners( @@ -112,6 +112,7 @@ export default class LDClientImpl implements LDClient { listeners.set('put', { deserializeData: JSON.parse, processJson: async (dataJson: Flags) => { + this.logger.debug(`Streamer PUT: ${dataJson}`); if (initializedFromStorage) { this.logger.debug('Synchronizing all data'); const changeset: LDFlagChangeset = {}; @@ -123,15 +124,15 @@ export default class LDClientImpl implements LDClient { changeset[k] = { previous: f.value }; } else if (!fastDeepEqual(f, flagFromPut)) { // flag changed - changeset[k] = { previous: f.value, current: flagFromPut }; + changeset[k] = { previous: f.value, current: flagFromPut.value }; } }); - Object.entries(dataJson).forEach(([k, v]) => { + Object.entries(dataJson).forEach(([k, f]) => { const flagFromStorage = this.flags[k]; if (!flagFromStorage) { // flag added - changeset[k] = { current: v }; + changeset[k] = { current: f.value }; } }); @@ -152,19 +153,40 @@ export default class LDClientImpl implements LDClient { listeners.set('patch', { deserializeData: JSON.parse, - processJson: (dataJson) => { - this.logger.debug(`Updating ${dataJson.key}`); - this.flags[dataJson.key] = dataJson; - this.emitter.emit('change', context, dataJson.key); + processJson: async (dataJson: PatchFlag) => { + this.logger.debug(`Streamer PATCH ${dataJson}`); + const existing = this.flags[dataJson.key]; + + // add flag if it doesn't exist + // if does, update it if version is newer + if (!existing || (existing && dataJson.version > existing.version)) { + this.flags[dataJson.key] = dataJson; + const changeset: LDFlagChangeset = { current: dataJson.value }; + + if (existing) { + changeset[dataJson.key].previous = existing.value; + } + + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); + this.emitter.emit('change', context, changeset); + } }, }); listeners.set('delete', { deserializeData: JSON.parse, - processJson: (dataJson) => { - this.logger.debug(`Deleting ${dataJson.key}`); - delete this.flags[dataJson.key]; - this.emitter.emit('change', context, dataJson.key); + processJson: (dataJson: DeleteFlag) => { + this.logger.debug(`Streamer DELETE ${dataJson}`); + const existing = this.flags[dataJson.key]; + + if (existing && existing.version <= dataJson.version) { + const changeset: LDFlagChangeset = {}; + changeset[dataJson.key] = { + previous: this.flags[dataJson.key].value, + }; + delete this.flags[dataJson.key]; + this.emitter.emit('change', context, changeset); + } }, }); diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 24b304b8b..2c8f223c8 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -84,7 +84,7 @@ export interface LDClient { * This is the context that was most recently passed to {@link identify}, or, if {@link identify} has never * been called, the initial context specified when the client was created. */ - getContext(): LDContext; + getContext(): LDContext | undefined; /** * Identifies a context to LaunchDarkly. diff --git a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts index ff714ea4d..d9486a0a1 100644 --- a/packages/shared/sdk-client/src/evaluation/fetchFlags.ts +++ b/packages/shared/sdk-client/src/evaluation/fetchFlags.ts @@ -3,7 +3,7 @@ import { LDContext, LDEvaluationReason, LDFlagValue, Platform } from '@launchdar import Configuration from '../configuration'; import { createFetchOptions, createFetchUrl } from './fetchUtils'; -export type Flag = { +export interface Flag { version: number; flagVersion: number; value: LDFlagValue; @@ -12,7 +12,13 @@ export type Flag = { trackReason?: boolean; reason?: LDEvaluationReason; debugEventsUntilDate?: number; -}; +} + +export interface PatchFlag extends Flag { + key: string; +} + +export type DeleteFlag = Pick; export type Flags = { [k: string]: Flag; From b281b38c08bff7f87a0509d7069e3ca24bcbd45e Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 14 Dec 2023 18:55:51 -0800 Subject: [PATCH 16/41] fix: when syncing only update memory cache and storage if there's changes. add patch and delete unit tests. mock streamer now supports patch and delete commands. --- .../shared/mocks/src/streamingProcessor.ts | 10 +++ .../src/LDClientImpl.storage.test.ts | 81 ++++++++++++++++--- .../shared/sdk-client/src/LDClientImpl.ts | 14 ++-- 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/packages/shared/mocks/src/streamingProcessor.ts b/packages/shared/mocks/src/streamingProcessor.ts index dbf9bf8e0..2b70d57ba 100644 --- a/packages/shared/mocks/src/streamingProcessor.ts +++ b/packages/shared/mocks/src/streamingProcessor.ts @@ -11,6 +11,8 @@ export const MockStreamingProcessor = jest.fn(); export const setupMockStreamingProcessor = ( shouldError: boolean = false, putResponseJson: any = { data: { flags: {}, segments: {} } }, + patchResponseJson?: any, + deleteResponseJson?: any, ) => { MockStreamingProcessor.mockImplementation( ( @@ -35,6 +37,14 @@ export const setupMockStreamingProcessor = ( } else { // execute put which will resolve the init promise process.nextTick(() => listeners.get('put')?.processJson(putResponseJson)); + + if (patchResponseJson) { + process.nextTick(() => listeners.get('patch')?.processJson(patchResponseJson)); + } + + if (deleteResponseJson) { + process.nextTick(() => listeners.get('delete')?.processJson(deleteResponseJson)); + } } }), close: jest.fn(), diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 86d1f9b52..657103756 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -2,7 +2,7 @@ import { clone, type LDContext, LDFlagChangeset } from '@launchdarkly/js-sdk-com import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks'; import LDEmitter from './api/LDEmitter'; -import type { Flag, Flags } from './evaluation/fetchFlags'; +import type { DeleteFlag, Flag, Flags, PatchFlag } from './evaluation/fetchFlags'; import * as mockResponseJson from './evaluation/mockResponse.json'; import LDClientImpl from './LDClientImpl'; @@ -42,10 +42,12 @@ const onChangePromise = () => // 3. Runs identify // 4. Get all flags const identifyGetAllFlags = async ( - putResponse = defaultPutResponse, shouldError: boolean = false, + putResponse = defaultPutResponse, + patchResponse?: PatchFlag, + deleteResponse?: DeleteFlag, ) => { - setupMockStreamingProcessor(shouldError, putResponse); + setupMockStreamingProcessor(shouldError, putResponse, patchResponse, deleteResponse); const changePromise = onChangePromise(); try { @@ -84,7 +86,7 @@ describe('sdk-client storage', () => { test('initialize from storage succeeds without streamer', async () => { // make sure streamer errors - const allFlags = await identifyGetAllFlags(defaultPutResponse, true); + const allFlags = await identifyGetAllFlags(true, defaultPutResponse); expect(basicPlatform.storage.get).toHaveBeenCalledWith('org:Testy Pizza'); @@ -113,7 +115,7 @@ describe('sdk-client storage', () => { test('syncing storage when a flag is deleted', async () => { const putResponse = clone(defaultPutResponse); delete putResponse['dev-test-flag']; - const allFlags = await identifyGetAllFlags(putResponse); + const allFlags = await identifyGetAllFlags(false, putResponse); expect(allFlags).not.toHaveProperty('dev-test-flag'); expect(basicPlatform.storage.set).toHaveBeenCalledWith( @@ -137,7 +139,7 @@ describe('sdk-client storage', () => { trackEvents: false, }; putResponse['another-dev-test-flag'] = newFlag; - const allFlags = await identifyGetAllFlags(putResponse); + const allFlags = await identifyGetAllFlags(false, putResponse); expect(allFlags).toMatchObject({ 'another-dev-test-flag': false }); expect(basicPlatform.storage.set).toHaveBeenCalledWith( @@ -153,7 +155,7 @@ describe('sdk-client storage', () => { const putResponse = clone(defaultPutResponse); putResponse['dev-test-flag'].version = 999; putResponse['dev-test-flag'].value = false; - const allFlags = await identifyGetAllFlags(putResponse); + const allFlags = await identifyGetAllFlags(false, putResponse); expect(allFlags).toMatchObject({ 'dev-test-flag': false }); expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { @@ -172,7 +174,7 @@ describe('sdk-client storage', () => { putResponse['dev-test-flag'].value = false; putResponse['another-dev-test-flag'] = newFlag; delete putResponse['moonshot-demo']; - const allFlags = await identifyGetAllFlags(putResponse); + const allFlags = await identifyGetAllFlags(false, putResponse); expect(allFlags).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); expect(allFlags).not.toHaveProperty('moonshot-demo'); @@ -190,7 +192,7 @@ describe('sdk-client storage', () => { const putResponse = clone(defaultPutResponse); putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; - const allFlags = await identifyGetAllFlags(putResponse); + const allFlags = await identifyGetAllFlags(false, putResponse); const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; expect(allFlags).toMatchObject({ 'dev-test-flag': true }); @@ -209,5 +211,64 @@ describe('sdk-client storage', () => { }); }); - // TODO: add tests for patch and delete listeners + test('patch should emit change event', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version += 1; + + const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + + expect(allFlags).toMatchObject({ 'dev-test-flag': false }); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + 'org:Testy Pizza', + expect.stringContaining(JSON.stringify(patchResponse)), + ); + expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); + expect(emitter.emit).toHaveBeenCalledTimes(3); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { + previous: true, + current: false, + }, + }); + }); + + test.todo('patch should ignore older version'); + test.todo('patch should add new flags'); + test.todo('delete should ignore newer version'); + test.todo('delete should ignore non-existing flag'); + + test('delete should emit change event', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version, + }; + + const allFlags = await identifyGetAllFlags( + false, + defaultPutResponse, + undefined, + deleteResponse, + ); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + + expect(allFlags).not.toHaveProperty('dev-test-flag'); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + 'org:Testy Pizza', + expect.not.stringContaining('dev-test-flag'), + ); + expect(flagsInStorage['dev-test-flag']).toBeUndefined(); + expect(emitter.emit).toHaveBeenCalledTimes(3); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'dev-test-flag': { + previous: true, + }, + }); + }); }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 9e2a905a8..63746763c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -136,9 +136,9 @@ export default class LDClientImpl implements LDClient { } }); - this.flags = dataJson; - await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); if (Object.keys(changeset).length > 0) { + this.flags = dataJson; + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); this.emitter.emit('change', context, changeset); } } else { @@ -160,13 +160,16 @@ export default class LDClientImpl implements LDClient { // add flag if it doesn't exist // if does, update it if version is newer if (!existing || (existing && dataJson.version > existing.version)) { - this.flags[dataJson.key] = dataJson; - const changeset: LDFlagChangeset = { current: dataJson.value }; + const changeset: LDFlagChangeset = {}; + changeset[dataJson.key] = { + current: dataJson.value, + }; if (existing) { changeset[dataJson.key].previous = existing.value; } + this.flags[dataJson.key] = dataJson; await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); this.emitter.emit('change', context, changeset); } @@ -175,7 +178,7 @@ export default class LDClientImpl implements LDClient { listeners.set('delete', { deserializeData: JSON.parse, - processJson: (dataJson: DeleteFlag) => { + processJson: async (dataJson: DeleteFlag) => { this.logger.debug(`Streamer DELETE ${dataJson}`); const existing = this.flags[dataJson.key]; @@ -185,6 +188,7 @@ export default class LDClientImpl implements LDClient { previous: this.flags[dataJson.key].value, }; delete this.flags[dataJson.key]; + await this.platform.storage?.set(canonicalKey, JSON.stringify(this.flags)); this.emitter.emit('change', context, changeset); } }, From 78074fa1e44264c3a89dd7c963de5b54c022c0a3 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 14:59:41 -0800 Subject: [PATCH 17/41] chore: added more patch unit tests. --- .../src/LDClientImpl.storage.test.ts | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 657103756..88f540748 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -46,6 +46,7 @@ const identifyGetAllFlags = async ( putResponse = defaultPutResponse, patchResponse?: PatchFlag, deleteResponse?: DeleteFlag, + waitForChange: boolean = true, ) => { setupMockStreamingProcessor(shouldError, putResponse, patchResponse, deleteResponse); const changePromise = onChangePromise(); @@ -58,7 +59,7 @@ const identifyGetAllFlags = async ( jest.runAllTicks(); // if streamer errors, don't wait for 'change' because it will not be sent. - if (!shouldError) { + if (waitForChange && !shouldError) { await changePromise; } @@ -188,6 +189,31 @@ describe('sdk-client storage', () => { }); }); + test('syncing storage when PUT is consistent so no updates needed', async () => { + const allFlags = await identifyGetAllFlags( + false, + defaultPutResponse, + undefined, + undefined, + false, + ); + + expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + + // this is defaultPutResponse + expect(allFlags).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + test('an update to inExperiment should emit change event', async () => { const putResponse = clone(defaultPutResponse); putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; @@ -237,8 +263,59 @@ describe('sdk-client storage', () => { }); }); - test.todo('patch should ignore older version'); - test.todo('patch should add new flags'); + test('patch should add new flags', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'another-dev-test-flag'; + + const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); + const flagsInStorage = JSON.parse(basicPlatform.storage.set.mock.calls[0][1]) as Flags; + + expect(allFlags).toHaveProperty('another-dev-test-flag'); + expect(basicPlatform.storage.set).toHaveBeenCalledTimes(1); + expect(basicPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + 'org:Testy Pizza', + expect.stringContaining(JSON.stringify(patchResponse)), + ); + expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); + expect(emitter.emit).toHaveBeenCalledTimes(3); + expect(emitter.emit).toHaveBeenNthCalledWith(3, 'change', context, { + 'another-dev-test-flag': { + current: true, + }, + }); + }); + + test('patch should ignore older version', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version -= 1; + + const allFlags = await identifyGetAllFlags( + false, + defaultPutResponse, + patchResponse, + undefined, + false, + ); + + expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + + // this is defaultPutResponse + expect(allFlags).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + test.todo('delete should ignore newer version'); test.todo('delete should ignore non-existing flag'); From 34ad378c1b8f97d051cbe7441b268b9bdbe60f44 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 15:30:18 -0800 Subject: [PATCH 18/41] chore: added delete unit tests. --- .../src/LDClientImpl.storage.test.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts index 88f540748..fd2b38fae 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts @@ -316,9 +316,6 @@ describe('sdk-client storage', () => { }); }); - test.todo('delete should ignore newer version'); - test.todo('delete should ignore non-existing flag'); - test('delete should emit change event', async () => { const deleteResponse = { key: 'dev-test-flag', @@ -348,4 +345,35 @@ describe('sdk-client storage', () => { }, }); }); + + test('delete should not delete newer version', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version - 1, + }; + + const allFlags = await identifyGetAllFlags( + false, + defaultPutResponse, + undefined, + deleteResponse, + false, + ); + + expect(allFlags).toHaveProperty('dev-test-flag'); + expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + }); + + test('delete should ignore non-existing flag', async () => { + const deleteResponse = { + key: 'does-not-exist', + version: 1, + }; + + await identifyGetAllFlags(false, defaultPutResponse, undefined, deleteResponse, false); + + expect(basicPlatform.storage.set).not.toHaveBeenCalled(); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + }); }); From 371351ce5b37fba61f72c1c98b2c8ffa40154387 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 15:42:06 -0800 Subject: [PATCH 19/41] Update LDClientImpl.ts --- packages/shared/sdk-client/src/LDClientImpl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 63746763c..3b2643e95 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -46,6 +46,7 @@ export default class LDClientImpl implements LDClient { private identifyErrorListener?: (c: LDContext, err: any) => void; private readonly clientContext: ClientContext; + /** * Creates the client object synchronously. No async, no network calls. */ From 30f5ace2f019b1d8a0afa4a28d330417a13467d7 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 15:48:06 -0800 Subject: [PATCH 20/41] chore: pretty print json debug output. --- packages/shared/sdk-client/src/LDClientImpl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 3b2643e95..13457956e 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -113,7 +113,7 @@ export default class LDClientImpl implements LDClient { listeners.set('put', { deserializeData: JSON.parse, processJson: async (dataJson: Flags) => { - this.logger.debug(`Streamer PUT: ${dataJson}`); + this.logger.debug(`Streamer PUT: ${JSON.stringify(dataJson, null, 2)}`); if (initializedFromStorage) { this.logger.debug('Synchronizing all data'); const changeset: LDFlagChangeset = {}; @@ -155,7 +155,7 @@ export default class LDClientImpl implements LDClient { listeners.set('patch', { deserializeData: JSON.parse, processJson: async (dataJson: PatchFlag) => { - this.logger.debug(`Streamer PATCH ${dataJson}`); + this.logger.debug(`Streamer PATCH ${JSON.stringify(dataJson, null, 2)}`); const existing = this.flags[dataJson.key]; // add flag if it doesn't exist @@ -180,7 +180,7 @@ export default class LDClientImpl implements LDClient { listeners.set('delete', { deserializeData: JSON.parse, processJson: async (dataJson: DeleteFlag) => { - this.logger.debug(`Streamer DELETE ${dataJson}`); + this.logger.debug(`Streamer DELETE ${JSON.stringify(dataJson, null, 2)}`); const existing = this.flags[dataJson.key]; if (existing && existing.version <= dataJson.version) { From 92361aa6e63d09400f368b739b8000a43236e3e4 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 16:03:52 -0800 Subject: [PATCH 21/41] fix: sse patch now updates react context. improved logging. TODO: fix infinite loop when flag is deleted. --- packages/sdk/react-native/src/provider/reactContext.ts | 2 +- packages/sdk/react-native/src/provider/setupListeners.ts | 6 +++++- packages/shared/sdk-client/src/LDClientImpl.ts | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react-native/src/provider/reactContext.ts b/packages/sdk/react-native/src/provider/reactContext.ts index 98e43eb8b..65426307e 100644 --- a/packages/sdk/react-native/src/provider/reactContext.ts +++ b/packages/sdk/react-native/src/provider/reactContext.ts @@ -6,7 +6,7 @@ export type ReactContext = { client: LDClient; context?: LDContext; dataSource: { - status?: 'initializing' | 'ready' | 'error'; + status?: 'initializing' | 'ready' | 'error' | 'change'; error?: Error; }; }; diff --git a/packages/sdk/react-native/src/provider/setupListeners.ts b/packages/sdk/react-native/src/provider/setupListeners.ts index c3e561482..1d77b350e 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.ts +++ b/packages/sdk/react-native/src/provider/setupListeners.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; -import { LDContext } from '@launchdarkly/js-client-sdk-common'; +import type { LDContext, LDFlagChangeset } from '@launchdarkly/js-client-sdk-common'; import ReactNativeLDClient from '../ReactNativeLDClient'; import { ReactContext } from './reactContext'; @@ -20,6 +20,10 @@ const setupListeners = ( client.on('error', (c: LDContext, e: any) => { setState({ client, context: c, dataSource: { status: 'error', error: e } }); }); + + client.on('change', (c: LDContext, _changes: LDFlagChangeset) => { + setState({ client, context: c, dataSource: { status: 'change' } }); + }); }; export default setupListeners; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 13457956e..5f09a8f32 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -321,10 +321,13 @@ export default class LDClientImpl implements LDClient { const found = this.flags[flagKey]; if (!found) { - const error = new LDClientError(`Unknown feature flag "${flagKey}"; returning default value`); + const defVal = defaultValue ?? null; + const error = new LDClientError( + `Unknown feature flag "${flagKey}"; returning default value ${defVal}`, + ); this.emitter.emit('error', this.context, error); this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, defaultValue ?? null, evalContext), + this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext), ); return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue); } From 78dc7291abf34018f12b13a1908f40b68a0d1f63 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Fri, 15 Dec 2023 16:11:15 -0800 Subject: [PATCH 22/41] chore: added TODO comment. --- packages/sdk/react-native/src/provider/setupListeners.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/react-native/src/provider/setupListeners.ts b/packages/sdk/react-native/src/provider/setupListeners.ts index 1d77b350e..0939d5df1 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.ts +++ b/packages/sdk/react-native/src/provider/setupListeners.ts @@ -18,6 +18,9 @@ const setupListeners = ( }); client.on('error', (c: LDContext, e: any) => { + // TODO: if a flag is deleted, variation will return the default value and + // emit an error. This setState will cause a re-render which will call + // variation again causing an infinite loop of setState and variation calls. setState({ client, context: c, dataSource: { status: 'error', error: e } }); }); From 7283b394acb146568eeb06a6bb45b0f98ae43934 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Mon, 18 Dec 2023 12:11:29 -0800 Subject: [PATCH 23/41] fix: remove unnecessary event listeners to fix infinite loops on errors. --- packages/sdk/react-native/example/App.tsx | 2 +- .../sdk/react-native/example/src/welcome.tsx | 28 ++++++++++++------- packages/sdk/react-native/src/hooks/index.ts | 3 +- .../src/hooks/useLDDataSourceStatus.ts | 10 ------- .../react-native/src/provider/LDProvider.tsx | 2 +- .../react-native/src/provider/reactContext.ts | 8 +----- .../src/provider/setupListeners.ts | 21 +++----------- .../shared/sdk-client/src/LDClientImpl.ts | 2 ++ 8 files changed, 28 insertions(+), 48 deletions(-) delete mode 100644 packages/sdk/react-native/src/hooks/useLDDataSourceStatus.ts diff --git a/packages/sdk/react-native/example/App.tsx b/packages/sdk/react-native/example/App.tsx index 56634e16e..b5148e3b7 100644 --- a/packages/sdk/react-native/example/App.tsx +++ b/packages/sdk/react-native/example/App.tsx @@ -9,7 +9,7 @@ const context = { kind: 'user', key: 'test-user-1' }; const App = () => { return ( - + ); diff --git a/packages/sdk/react-native/example/src/welcome.tsx b/packages/sdk/react-native/example/src/welcome.tsx index dd42ebbf6..48de11bda 100644 --- a/packages/sdk/react-native/example/src/welcome.tsx +++ b/packages/sdk/react-native/example/src/welcome.tsx @@ -1,27 +1,29 @@ -import { Button, StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; +import { Button, StyleSheet, Text, TextInput, View } from 'react-native'; -import { - useBoolVariation, - useLDClient, - useLDDataSourceStatus, -} from '@launchdarkly/react-native-client-sdk'; +import { useBoolVariation, useLDClient } from '@launchdarkly/react-native-client-sdk'; export default function Welcome() { - const { error, status } = useLDDataSourceStatus(); const flag = useBoolVariation('dev-test-flag', false); const ldc = useLDClient(); + const [userKey, setUserKey] = useState('test-user-1'); const login = () => { - ldc.identify({ kind: 'user', key: 'test-user-2' }); + console.log(`identifying: ${userKey}`); + ldc.identify({ kind: 'user', key: userKey }); }; return ( Welcome to LaunchDarkly - status: {status ?? 'not connected'} - {error ? error: {error.message} : null} devTestFlag: {`${flag}`} context: {JSON.stringify(ldc.getContext())} +