From c4dab5b3a811a6893c0d9db69b547cd346ff1c21 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Sat, 8 Feb 2025 21:33:53 +0000 Subject: [PATCH 1/2] feat(node-sdk): improve documentation and type safety for client methods - Enhanced JSDoc comments for BucketClient methods - Added more detailed parameter descriptions - Improved type checking for meta context - Extracted meta validation into a separate function - Updated type definitions for context and tracking options - Refined error messages for validation --- packages/node-sdk/package.json | 2 +- packages/node-sdk/src/client.ts | 234 ++++++++++++++++++-------- packages/node-sdk/src/types.ts | 6 + packages/node-sdk/test/client.test.ts | 28 +-- 4 files changed, 191 insertions(+), 79 deletions(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 81766026..3ba99cea 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.5.1", + "version": "1.5.3", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index ba55d857..e4a5665c 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -85,6 +85,16 @@ type BulkEvent = /** * The SDK client. * + * @remarks + * This is the main class for interacting with Bucket. + * It is used to update user and company contexts, track events, and evaluate feature flags. + * + * @example + * ```ts + * const client = new BucketClient({ + * secretKey: "your-secret-key", + * }); + * ``` **/ export class BucketClient { private _config: { @@ -114,6 +124,16 @@ export class BucketClient { * See README for configuration options. * * @param options - The options for the client or an existing client to clone. + * @param options.apiBaseUrl - The base URL to send requests to (optional). + * @param options.logger - The logger to use for logging (optional). + * @param options.httpClient - The HTTP client to use for sending requests (optional). + * @param options.logLevel - The log level to use for logging (optional). + * @param options.offline - Whether to run in offline mode (optional). + * @param options.fallbackFeatures - The fallback features to use if the feature is not found (optional). + * @param options.batchOptions - The options for the batch buffer (optional). + * @param options.featureOverrides - The feature overrides to use for the client (optional). + * @param options.configFile - The path to the config file (optional). + * * @throws An error if the options are invalid. **/ constructor(options: ClientOptions = {}) { @@ -231,6 +251,15 @@ export class BucketClient { return this._config.logger; } + /** + * Sets the feature overrides. + * + * @param overrides - The feature overrides. + * + * @remarks + * The feature overrides are used to override the feature definitions. + * This is useful for testing or development. + **/ set featureOverrides(overrides: FeatureOverridesFn) { this._config.featureOverrides = overrides; } @@ -240,8 +269,16 @@ export class BucketClient { * set to be used in subsequent calls. * For example, for evaluating feature targeting or tracking events. * + * @param context - The context to bind the client to. + * @param context.enableTracking - Whether to enable tracking for the context. + * @param context.user - The user context. + * @param context.company - The company context. + * @param context.other - The other context. + * * @returns A new client bound with the arguments given. + * * @throws An error if the user/company is given but their ID is not a string. + * * @remarks * The `updateUser` / `updateCompany` methods will automatically be called when * the user/company is set respectively. @@ -256,36 +293,37 @@ export class BucketClient { /** * Updates the associated user in Bucket. * - * @param opts.attributes - The additional attributes of the company (optional). - * @param opts.meta - The meta context associated with tracking (optional). + * @param userId - The userId of the user to update. + * @param options - The options for the user. + * @param options.attributes - The additional attributes of the user (optional). + * @param options.meta - The meta context associated with tracking (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks * The company must be set using `withCompany` before calling this method. * If the user is set, the company will be associated with the user. **/ - public async updateUser(userId: IdType, opts?: TrackOptions) { + public async updateUser(userId: IdType, options?: TrackOptions) { idOk(userId, "userId"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); - ok( - opts?.meta === undefined || isObject(opts.meta), - "meta must be an object", - ); + checkMeta(options?.meta); if (this._config.offline) { return; } - if (this._config.rateLimiter.isAllowed(hashObject({ ...opts, userId }))) { + if ( + this._config.rateLimiter.isAllowed(hashObject({ ...options, userId })) + ) { await this._config.batchBuffer.add({ type: "user", userId, - attributes: opts?.attributes, - context: opts?.meta, + attributes: options?.attributes, + context: options?.meta, }); } } @@ -293,8 +331,11 @@ export class BucketClient { /** * Updates the associated company in Bucket. * - * @param opts.attributes - The additional attributes of the company (optional). - * @param opts.meta - The meta context associated with tracking (optional). + * @param companyId - The companyId of the company to update. + * @param options - The options for the company. + * @param options.attributes - The additional attributes of the company (optional). + * @param options.meta - The meta context associated with tracking (optional). + * @param options.userId - The userId of the user to associate with the company (optional). * * @throws An error if the company is not set or the options are invalid. * @remarks @@ -303,20 +344,18 @@ export class BucketClient { **/ public async updateCompany( companyId: IdType, - opts?: TrackOptions & { userId?: IdType }, + options?: TrackOptions & { userId?: IdType }, ) { idOk(companyId, "companyId"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); - ok( - opts?.meta === undefined || isObject(opts.meta), - "meta must be an object", - ); - if (typeof opts?.userId !== "undefined") { - idOk(opts?.userId, "userId"); + checkMeta(options?.meta); + + if (typeof options?.userId !== "undefined") { + idOk(options?.userId, "userId"); } if (this._config.offline) { @@ -324,14 +363,14 @@ export class BucketClient { } if ( - this._config.rateLimiter.isAllowed(hashObject({ ...opts, companyId })) + this._config.rateLimiter.isAllowed(hashObject({ ...options, companyId })) ) { await this._config.batchBuffer.add({ type: "company", companyId, - userId: opts?.userId, - attributes: opts?.attributes, - context: opts?.meta, + userId: options?.userId, + attributes: options?.attributes, + context: options?.meta, }); } } @@ -353,21 +392,21 @@ export class BucketClient { public async track( userId: IdType, event: string, - opts?: TrackOptions & { companyId?: IdType }, + options?: TrackOptions & { companyId?: IdType }, ) { idOk(userId, "userId"); ok(typeof event === "string" && event.length > 0, "event must be a string"); - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); ok( - opts?.attributes === undefined || isObject(opts.attributes), + options?.attributes === undefined || isObject(options.attributes), "attributes must be an object", ); ok( - opts?.meta === undefined || isObject(opts.meta), + options?.meta === undefined || isObject(options.meta), "meta must be an object", ); - if (opts?.companyId !== undefined) { - idOk(opts?.companyId, "companyId"); + if (options?.companyId !== undefined) { + idOk(options?.companyId, "companyId"); } if (this._config.offline) { @@ -377,18 +416,16 @@ export class BucketClient { await this._config.batchBuffer.add({ type: "event", event, - companyId: opts?.companyId, + companyId: options?.companyId, userId, - attributes: opts?.attributes, - context: opts?.meta, + attributes: options?.attributes, + context: options?.meta, }); } /** * Initializes the client by caching the features definitions. * - * @returns void - * * @remarks * Call this method before calling `getFeatures` to ensure the feature definitions are cached. * The client will ignore subsequent calls to this method. @@ -410,9 +447,17 @@ export class BucketClient { } /** - * Gets the evaluated feature for the current context which includes the user, company, and custom context. + * Gets the evaluated features for the current context which includes the user, company, and custom context. + * + * @param options - The options for the context. + * @param options.enableTracking - Whether to enable tracking for the context. + * @param options.meta - The meta context associated with the context. + * @param options.user - The user context. + * @param options.company - The company context. + * @param options.other - The other context. * * @returns The evaluated features. + * * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ @@ -435,7 +480,9 @@ export class BucketClient { * Gets the evaluated feature for the current context which includes the user, company, and custom context. * Using the `isEnabled` property sends a `check` event to Bucket. * - * @returns The evaluated features. + * @param key - The key of the feature to get. + * @returns The evaluated feature. + * * @remarks * Call `initialize` before calling this method to ensure the feature definitions are cached, no features will be returned otherwise. **/ @@ -458,9 +505,10 @@ export class BucketClient { * Gets evaluated features with the usage of remote context. * This method triggers a network request every time it's called. * - * @param userId - * @param companyId - * @param additionalContext + * @param userId - The userId of the user to get the features for. + * @param companyId - The companyId of the company to get the features for. + * @param additionalContext - The additional context to get the features for. + * * @returns evaluated features */ public async getFeaturesRemote( @@ -480,10 +528,11 @@ export class BucketClient { * Gets evaluated feature with the usage of remote context. * This method triggers a network request every time it's called. * - * @param key - * @param userId - * @param companyId - * @param additionalContext + * @param key - The key of the feature to get. + * @param userId - The userId of the user to get the feature for. + * @param companyId - The companyId of the company to get the feature for. + * @param additionalContext - The additional context to get the feature for. + * * @returns evaluated feature */ public async getFeatureRemote( @@ -515,7 +564,9 @@ export class BucketClient { * * @param path - The path to send the request to. * @param body - The body of the request. + * * @returns A boolean indicating if the request was successful. + * * @throws An error if the path or body is invalid. **/ private async post(path: string, body: TBody) { @@ -552,6 +603,7 @@ export class BucketClient { * Sends a GET request to the specified path. * * @param path - The path to send the request to. + * * @returns The response from the server. * @throws An error if the path is invalid. **/ @@ -588,7 +640,9 @@ export class BucketClient { /** * Sends a batch of events to the Bucket API. + * * @param events - The events to send. + * * @throws An error if the send fails. **/ private async sendBulkEvents(events: BulkEvent[]) { @@ -612,6 +666,13 @@ export class BucketClient { * the current context. * * @param event - The event to send. + * @param event.action - The action to send. + * @param event.key - The key of the feature to send. + * @param event.targetingVersion - The targeting version of the feature to send. + * @param event.evalResult - The evaluation result of the feature to send. + * @param event.evalContext - The evaluation context of the feature to send. + * @param event.evalRuleResults - The evaluation rule results of the feature to send. + * @param event.evalMissingFields - The evaluation missing fields of the feature to send. * * @throws An error if the event is invalid. * @@ -691,9 +752,14 @@ export class BucketClient { * Updates the context in Bucket (if needed). * This method should be used before requesting feature flags or binding a client. * - * @param options + * @param options - The options for the context. + * @param options.enableTracking - Whether to enable tracking for the context. + * @param options.meta - The meta context associated with the context. + * @param options.user - The user context. + * @param options.company - The company context. + * @param options.other - The other context. */ - private async syncContext(options: { enableTracking: boolean } & Context) { + private async syncContext(options: ContextWithTracking) { if (!options.enableTracking) { this._config.logger?.debug( "tracking disabled, not updating user/company", @@ -708,7 +774,7 @@ export class BucketClient { promises.push( this.updateCompany(options.company.id, { attributes, - meta: { active: false }, + meta: options.meta, }), ); } @@ -718,7 +784,7 @@ export class BucketClient { promises.push( this.updateUser(options.user.id, { attributes, - meta: { active: false }, + meta: options.meta, }), ); } @@ -790,7 +856,7 @@ export class BucketClient { } private _getFeatures( - options: { enableTracking: boolean } & Context, + options: ContextWithTracking, ): Record { checkContextWithTracking(options); @@ -815,7 +881,7 @@ export class BucketClient { featureDefinitions.map((f) => [f.key, f.targeting.version]), ); - const { enableTracking = true, ...context } = options; + const { enableTracking = true, meta: _, ...context } = options; const evaluated = featureDefinitions.map((feature) => evaluateFeatureRules({ @@ -987,6 +1053,14 @@ export class BoundBucketClient { private readonly _client: BucketClient; private readonly _options: ContextWithTracking; + /** + * Creates a new BoundBucketClient. + * + * @param client - The `BucketClient` to use. + * @param options - The options for the client. + * @param options.enableTracking - Whether to enable tracking for the client. + * @param options.context - The context for the client. + */ constructor( client: BucketClient, { enableTracking = true, ...context }: ContextWithTracking, @@ -1040,6 +1114,8 @@ export class BoundBucketClient { * Get a specific feature for the user/company/other context bound to this client. * Using the `isEnabled` property sends a `check` event to Bucket. * + * @param key - The key of the feature to get. + * * @returns Features for the given user/company and whether each one is enabled or not */ public getFeature(key: keyof TypedFeatures) { @@ -1052,18 +1128,19 @@ export class BoundBucketClient { * @returns Features for the given user/company and whether each one is enabled or not */ public async getFeaturesRemote() { - const { enableTracking: _, ...context } = this._options; + const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFeaturesRemote(undefined, undefined, context); } /** * Get remotely evaluated feature for the user/company/other context bound to this client. * - * @param key + * @param key - The key of the feature to get. + * * @returns Feature for the given user/company and key and whether it's enabled or not */ public async getFeatureRemote(key: string) { - const { enableTracking: _, ...context } = this._options; + const { enableTracking: _, meta: __, ...context } = this._options; return await this._client.getFeatureRemote( key, undefined, @@ -1076,18 +1153,19 @@ export class BoundBucketClient { * Track an event in Bucket. * * @param event - The event to track. - * @param opts - * @param opts.attributes - The attributes of the event (optional). - * @param opts.meta - The meta context associated with tracking (optional). - * @param opts.companyId - Optional company ID for the event (optional). + * @param options - The options for the event. + * @param options.attributes - The attributes of the event (optional). + * @param options.meta - The meta context associated with tracking (optional). + * @param options.companyId - Optional company ID for the event (optional). * * @throws An error if the event is invalid or the options are invalid. */ public async track( event: string, - opts?: TrackOptions & { companyId?: string }, + options?: TrackOptions & { companyId?: string }, ) { - ok(opts === undefined || isObject(opts), "opts must be an object"); + ok(options === undefined || isObject(options), "options must be an object"); + checkMeta(options?.meta); const userId = this._options.user?.id; @@ -1106,9 +1184,9 @@ export class BoundBucketClient { await this._client.track( userId, event, - opts?.companyId - ? opts - : { ...opts, companyId: this._options.company?.id }, + options?.companyId + ? options + : { ...options, companyId: this._options.company?.id }, ); } @@ -1116,6 +1194,13 @@ export class BoundBucketClient { * Create a new client bound with the additional context. * Note: This performs a shallow merge for user/company/other individually. * + * @param context - The context to bind the client to. + * @param context.user - The user to bind the client to. + * @param context.company - The company to bind the client to. + * @param context.other - The other context to bind the client to. + * @param context.enableTracking - Whether to enable tracking for the client. + * @param context.meta - The meta context to bind the client to. + * * @returns new client bound with the additional context */ public bindClient({ @@ -1123,7 +1208,8 @@ export class BoundBucketClient { company, other, enableTracking, - }: Context & { enableTracking?: boolean }) { + meta, + }: ContextWithTracking) { // merge new context into existing const boundConfig = { ...this._options, @@ -1131,6 +1217,7 @@ export class BoundBucketClient { company: company ? { ...this._options.company, ...company } : undefined, other: { ...this._options.other, ...other }, enableTracking: enableTracking ?? this._options.enableTracking, + meta: meta ?? this._options.meta, }; return new BoundBucketClient(this._client, boundConfig); @@ -1144,6 +1231,19 @@ export class BoundBucketClient { } } +function checkMeta( + meta?: TrackingMeta, +): asserts meta is TrackingMeta | undefined { + ok( + typeof meta === "undefined" || isObject(meta), + "meta must be an object if given", + ); + ok( + meta?.active === undefined || typeof meta?.active === "boolean", + "meta.active must be a boolean if given", + ); +} + function checkContextWithTracking( context: ContextWithTracking, ): asserts context is ContextWithTracking & { enableTracking: boolean } { @@ -1172,4 +1272,6 @@ function checkContextWithTracking( typeof context.enableTracking === "boolean", "enableTracking must be a boolean", ); + + checkMeta(context.meta); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 25b8b8b2..b54c3e8b 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -462,6 +462,12 @@ export interface ContextWithTracking extends Context { * If set to `false`, tracking will be disabled for the context. Default is `true`. */ enableTracking?: boolean; + + /** + * The meta context used to update the user or company when syncing is required during + * feature retrieval. + */ + meta?: TrackingMeta; } export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const; diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ed6fac69..a34dcd32 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -368,9 +368,7 @@ describe("BucketClient", () => { type: "user", userId: user.id, attributes: attributes, - context: { - active: false, - }, + context: undefined, }, ], ); @@ -379,7 +377,7 @@ describe("BucketClient", () => { }); it("should update company in Bucket when called", async () => { - client.bindClient({ company: context.company }); + client.bindClient({ company: context.company, meta: { active: true } }); await client.flush(); const { id: _, ...attributes } = context.company; @@ -393,7 +391,7 @@ describe("BucketClient", () => { companyId: company.id, attributes: attributes, context: { - active: false, + active: true, }, }, ], @@ -527,7 +525,7 @@ describe("BucketClient", () => { it("should throw an error if opts are not valid or the user is not set", async () => { await expect( client.updateUser(user.id, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateUser(user.id, { attributes: "bad_attributes" as any }), @@ -619,7 +617,7 @@ describe("BucketClient", () => { it("should throw an error if company is not valid", async () => { await expect( client.updateCompany(company.id, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( client.updateCompany(company.id, { @@ -776,7 +774,7 @@ describe("BucketClient", () => { await expect( boundClient.track(event.event, "bad_opts" as any), - ).rejects.toThrow("opts must be an object"); + ).rejects.toThrow("validation failed: options must be an object"); await expect( boundClient.track(event.event, { @@ -967,7 +965,13 @@ describe("BucketClient", () => { // test that the feature is returned await client.initialize(); const feature = client.getFeature( - { ...context, enableTracking: true }, + { + ...context, + meta: { + active: true, + }, + enableTracking: true, + }, "feature1", ); await feature.track(); @@ -984,7 +988,7 @@ describe("BucketClient", () => { }, companyId: "company123", context: { - active: false, + active: true, }, type: "company", }, @@ -994,7 +998,7 @@ describe("BucketClient", () => { name: "John", }, context: { - active: false, + active: true, }, type: "user", userId: "user123", @@ -1278,7 +1282,7 @@ describe("BucketClient", () => { client.getFeatures({ user, company, other: otherContext }); expect(isAllowedSpy).toHaveBeenCalledWith( - "f1e5f547723da57ad12375f304e44ed6f74c744e", + "d461e93fe41f6297ab43402d0fc6d63e2444e07d", ); }); From e44fe3d9a6e36f146d8f3bae854ebbf5ac9f1201 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Sun, 9 Feb 2025 19:19:11 +0000 Subject: [PATCH 2/2] fix(node-sdk): revert version to 1.5.2 and improve client documentation - Reverted package version from 1.5.3 to 1.5.2 - Enhanced JSDoc comments for BucketClient constructor - Added documentation for `secretKey` parameter - Marked BoundBucketClient constructor as internal --- packages/node-sdk/package.json | 2 +- packages/node-sdk/src/client.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 3ba99cea..81bde285 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.5.3", + "version": "1.5.2", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index e4a5665c..7fb53be9 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -124,6 +124,7 @@ export class BucketClient { * See README for configuration options. * * @param options - The options for the client or an existing client to clone. + * @param options.secretKey - The secret key to use for the client. * @param options.apiBaseUrl - The base URL to send requests to (optional). * @param options.logger - The logger to use for logging (optional). * @param options.httpClient - The HTTP client to use for sending requests (optional). @@ -133,6 +134,7 @@ export class BucketClient { * @param options.batchOptions - The options for the batch buffer (optional). * @param options.featureOverrides - The feature overrides to use for the client (optional). * @param options.configFile - The path to the config file (optional). + * * @throws An error if the options are invalid. **/ @@ -1054,12 +1056,14 @@ export class BoundBucketClient { private readonly _options: ContextWithTracking; /** - * Creates a new BoundBucketClient. + * (Internal) Creates a new BoundBucketClient. Use `bindClient` to create a new client bound with a specific context. * * @param client - The `BucketClient` to use. * @param options - The options for the client. * @param options.enableTracking - Whether to enable tracking for the client. * @param options.context - The context for the client. + * + * @internal */ constructor( client: BucketClient,