diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..02371d1 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,26 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node/.devcontainer/base.Dockerfile + +# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster +ARG VARIANT="$VARIANT" +FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} + + +RUN if [ ! -d /workspaces \]; then mkdir /workspaces; fi && chown -R $USER_UID:$USER_GID /workspaces + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment if you want to install an additional version of node using nvm +# ARG EXTRA_NODE_VERSION=10 +# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" + +# [Optional] Uncomment if you want to install more global node packages +# RUN su node -c "npm install -g " + +# Install kubectl +RUN curl -sSL -o /usr/local/bin/kubectl https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl \ + && chmod +x /usr/local/bin/kubectl + +# Install Helm +RUN curl -s https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4080153 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node +{ + "name": "Feature tog-node", + //runArgs is used to set container name to USERNAME.programname + //the reason it shows ${localEnv:USERNAME} is so that it covers both Windwos and *nix systems as frontend VSCode + "runArgs": ["--init", "--network=host", "--name=tog-node"], + "build": { + "dockerfile": "Dockerfile", + // Update 'VARIANT' to pick a Node version: 16, 14, 12. + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "args": { + "VARIANT": "16-bullseye" + } + }, + + "portsAttributes": { + "9080": { + "label": "api port", + "onAutoForward": "notify" + } + }, + // Set *default* container specific settings.json values on container create. + "settings": {}, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "dbaeumer.vscode-eslint", + "ms-azuretools.vscode-docker", + "mindaro-dev.file-downloader", + "github.vscode-pull-request-github", + "redhat.vscode-yaml" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "node", + "features": { + "docker-from-docker": { + "version": "latest", + "moby": true + }, + "kubectl-helm-minikube": "latest", + "git": "latest", + "github-cli": "latest" + }, + + // install all dependent packages after container is built and files copied into + "postCreateCommand": "npm install -g npm@^7.24.2 && npm install husky --save-dev && npm set-script prepare \"husky install\" && npm run prepare", + "postStartCommand": "npm update -g" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6e6a8d4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/lib/index.js", + "preLaunchTask": "tsc: build - tsconfig.json", + "outFiles": [ + "${workspaceFolder}/lib/**/*.js" + ] + }, + { + "name": "Test via NPM test", + "type": "node", + "request": "launch", + "runtimeArgs": ["run-script", "test"], + "runtimeExecutable": "npm", + "skipFiles": ["/**"], + "sourceMaps": true, + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceRoot}", + "console": "integratedTerminal" + }, + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 00c6429..e99a072 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ const { SessionClient } = require('tog-node') const sessions = new SessionClient('redis://127.0.0.1:6379') // wherever you whish to retrieve a session -const session = await sessions.session('my_app', 'session-123-xyz') +const session = await sessions.session('my_app', 'session-123-xyz', ["session traits 1","session traits 2"]) const buttonColor = session.flags['blue-button'] ? 'blue' : 'red' ``` @@ -43,7 +43,9 @@ await flags.saveFlag({ name: 'blue-button', description: 'Makes the call-to-action button blue', rollout: [ + { value: false} { percentage: 30, value: true } // will be `true` for 30% of users + { traits:["beta","power_user"], percentage: 60, value true} // for sessions that match both traits "beta" and "power_user", 60% of the chances will be true ] }) diff --git a/package-lock.json b/package-lock.json index b773bac..4d6170f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "ioredis": "^4.17.3", - "murmurhash-js": "^1.0.0" + "murmurhash-js": "^1.0.0", + "typescript": "^4.5.5" }, "devDependencies": { "@babel/core": "^7.10.2", @@ -20,10 +21,10 @@ "@types/jest": "^27.4.0", "@types/murmurhash-js": "^1.0.3", "babel-jest": "^25.5.1", + "husky": "^8.0.1", "jest": "^27.5.1", "standard-version": "^9.0.0", "typedoc": "^0.22.11", - "typescript": "^3.9.5", "wait-for-expect": "^3.0.2" } }, @@ -5060,6 +5061,21 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9180,9 +9196,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/minimist-options": { @@ -11272,10 +11288,9 @@ } }, "node_modules/typescript": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", - "dev": true, + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15897,6 +15912,12 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "husky": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz", + "integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==", + "dev": true + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -19055,9 +19076,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "minimist-options": { @@ -20699,10 +20720,9 @@ } }, "typescript": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", - "dev": true + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==" }, "uglify-js": { "version": "3.9.4", diff --git a/package.json b/package.json index e6e9896..692e2ed 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "test:watch": "jest --watch --runInBand", "docs": "typedoc", "build": "tsc", - "release": "./scripts/release.sh" + "release": "./scripts/release.sh", + "prepare": "husky install" }, "files": [ "/lib" @@ -18,7 +19,8 @@ "license": "ISC", "dependencies": { "ioredis": "^4.17.3", - "murmurhash-js": "^1.0.0" + "murmurhash-js": "^1.0.0", + "typescript": "^4.5.5" }, "devDependencies": { "@babel/core": "^7.10.2", @@ -28,10 +30,10 @@ "@types/jest": "^27.4.0", "@types/murmurhash-js": "^1.0.3", "babel-jest": "^25.5.1", + "husky": "^8.0.1", "jest": "^27.5.1", "standard-version": "^9.0.0", "typedoc": "^0.22.11", - "typescript": "^3.9.5", "wait-for-expect": "^3.0.2" }, "babel": { @@ -46,5 +48,27 @@ ], "@babel/preset-typescript" ] + }, + "jest": { + "verbose": true, + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "testRegex": "\\.(spec|test)\\.ts$", + "collectCoverage": true, + "collectCoverageFrom": [ + "/src/**/*.(t|j)s" + ], + "coveragePathIgnorePatterns": [ + "/node_modules", + "/migrations", + "/dist", + "/test" + ], + "coverageDirectory": "/test/coverage", + "testEnvironment": "node" } } diff --git a/src/index.ts b/src/index.ts index a06c6b3..7a0c9d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './types' export * from './flagClient' export * from './sessionClient' +export {resolveState} from './sessions' \ No newline at end of file diff --git a/src/serviceClien.ts b/src/serviceClien.ts new file mode 100644 index 0000000..31aee18 --- /dev/null +++ b/src/serviceClien.ts @@ -0,0 +1,113 @@ +import RedisClient from 'ioredis'; + +import { Flag, Session, SessionOptions, SessionClientOptions } from "./types"; +import { resolveState } from './sessions'; +import { FlagClient } from "./flagClient"; +import { Redis } from "./redis"; +import { Logger, defaultLogger } from "./logger"; +import { namespaceChangedKey } from "./keys"; + +const DEFAULT_TIMEOUT = 300 + +/** + * A client consuming sessions + * + * ```js + * const { SessionClient } = require('tog-node') + * + * const tog = new SessionClient('redis://127.0.0.1:6379') + * ``` + */ +export class ServiceClient { + private readonly options: SessionClientOptions + private readonly logger: Logger + private readonly flags: FlagClient + private availableFlags: Flag[] + private readonly namespace: string + readonly redis: Redis + readonly subscriber: Redis + readonly cache: {[namespace: string]: Promise} + + // every instance of ServiceClient intialized should be for one namespace only: + /** + * @param redisUrl The Redis connection string + * @param namespace The application namespace this ServiceClient serves + * @param options The client options {timeout, logger} + */ + constructor(redisUrl: string, namespace: string, options: SessionClientOptions = {}) { + this.options = options + this.logger = options.logger || defaultLogger + this.namespace = namespace || "" + this.flags = new FlagClient(redisUrl, options) + this.availableFlags=[] + this.redis = this.flags.redis + this.cache = {} + this.listFlagsWithTimeout(this.options.timeout || DEFAULT_TIMEOUT ) + + this.subscriber = options.cluster + ? new RedisClient.Cluster([redisUrl]) + : new RedisClient(redisUrl) + + this.subscriber.subscribe(namespaceChangedKey) + this.subscriber.on('message', (key, namespace) => this.clearCache(namespace)) + } + + /** + * Resolves a session, either by retrieving it or by computing a new one + * @param id Unique session ID + * @param traits Properties a session has, i.e. admin-user, or production env + * @param options Options used when creating the flag, which are ignored if it already exists + */ + async flagsForSession(id: string, traits?: string[], options?: SessionOptions): Promise { + const namespace = this.namespace; + try { + const flagOverrides = options && options.flags || {} + const flags = this.availableFlags + .reduce((all, flag) => ({ + ...all, + [flag.name]: resolveState(flag.rollout, flag.timestamp || 0, id, traits ?? []) + }), {}) + const session: Session = { + namespace, + id, + flags: { ...flags, ...flagOverrides } + } + + return session + } catch (e) { + this.logger.error(e) + return { namespace, id, flags: {} } + } + } + + private clearCache(namespace: string) { + this.logger.info(`namespace ${namespace} has changed`) + delete(this.cache[namespace]) + } + + private listFlags(namespace: string): Promise { + return this.cache[namespace] || (this.cache[namespace] = this.flags.listFlags(namespace)) + } + + + /** + * @hidden + */ + private listFlagsWithTimeout(durationMs: number): Promise { + let timeout: NodeJS.Timeout + const timeoutPromise: Promise = new Promise((resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`timeout after ${durationMs}ms`)), durationMs) + }) + //this.availableFlags = this.listFlags(this.namespace) + return Promise.race([ + this.listFlags(this.namespace).then(res => { + clearTimeout(timeout) + this.availableFlags = res + return res + }), + timeoutPromise + ]) + } + + +} diff --git a/src/sessionClient.ts b/src/sessionClient.ts index 82dadd9..84d93fa 100644 --- a/src/sessionClient.ts +++ b/src/sessionClient.ts @@ -50,7 +50,7 @@ export class SessionClient { * @param id Unique session ID * @param options Options used when creating the flag, which are ignored if it already exists */ - async session(namespace: string, id: string, options?: SessionOptions): Promise { + async session(namespace: string, id: string, traits?: string[], options?: SessionOptions): Promise { try { const flagOverrides = options && options.flags || {} const availableFlags = await withTimeout( @@ -59,7 +59,7 @@ export class SessionClient { const flags = availableFlags .reduce((all, flag) => ({ ...all, - [flag.name]: resolveState(flag.rollout, flag.timestamp || 0, id) + [flag.name]: resolveState(flag.rollout, flag.timestamp || 0, id, traits ?? []) }), {}) const session: Session = { namespace, diff --git a/src/sessions.test.ts b/src/sessions.test.ts index 2c02f5e..f677c7f 100644 --- a/src/sessions.test.ts +++ b/src/sessions.test.ts @@ -5,41 +5,51 @@ describe('pick variant', () => { Array(reps).fill(0) .map((n, i) => pick(i)) .map(r => r && r.toString()) - .reduce((acc, r) => ({ ...acc, [r]: (acc[r] || 0) + 1 }), {}) + .reduce((acc, r) => ({ ...acc, [r.toString()]: (acc[r.toString()] || 0) + 1 }), {}) - test('returns undefined for no variants', () => + /// test new resolve with traits + test('No rollout defined, return false', () => expect(resolveState([], 0, 'abc')).toBe(false)) - test('always returns the same variant with percentage 100', () => { - const n = 1000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 100 } - ], i, 'def')) + test('Rollout without traits defined, session with traits return true', () => + expect(resolveState([{ value: true }], 0, 'abc', ["blue"])).toBe(true)) + + test('Rollout traits match exact session traits, return true', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc', ["blue"])).toBe(true)) + + test('Rollout traits defined but session traits empty, return false', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc')).toBe(false)) + + test('Rollout traits defined but not match session traits, return false', () => + expect(resolveState([{ value: true, traits: ["blue"]}], 0, 'abc', ["red"])).toBe(false)) + + test('Rollout traits defined with more elements than session traits, return false', () => + expect(resolveState([{ value: true, traits: ["blue", "circle"]}], 0, 'abc', ["blue"])).toBe(false)) + + test('Multiple Rollout defined more traits matching session traits triumph', () => + expect(resolveState([{ value: true, traits: ["blue", "circle"]},{ value: false, traits: ["blue"]}], 0, 'abc', ["blue","circle"])).toBe(true)) + test('Multiple Rollout defined matching session same number of traits false triumph', () => + expect(resolveState([{ value: true, traits: ["blue"]},{ value: false, traits: ["circle"]}], 0, 'abc', ["blue","circle"])).toBe(false)) + + test('Rollout with percentage 100 matches exact session traits always return true', () => { + const n = 1000 + const distribution = getDistribution(n, i => resolveState( + [{ value: true, percentage: 100 , traits:["blue","circle"]}], + i, + 'def', + ["blue","circle"])) expect(distribution).toEqual({ true: n }) }) - test('correct distribution with complementing weights', () => { - const n = 10000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 70 }, - { value: false, percentage: 30 } - ], i, 'ghi')) - - expect(distribution['true'] + distribution['false']).toEqual(n) + test('Multiple Rollouts both true, higher percentage triumph', () => { + const n = 1000 + const distribution = getDistribution(n, i => resolveState( + [{ value: true, percentage: 20 , traits:["blue","circle"]}, { value: true, percentage: 70 , traits:["big","circle"]}], + i, + 'def', + ["blue","circle","big"])) expect(distribution['true'] / n).toBeCloseTo(0.7, 1) - expect(distribution['false'] / n).toBeCloseTo(0.3, 1) }) - test('correct distribution with incomplete variants', () => { - const n = 10000 - const distribution = getDistribution(n, i => resolveState([ - { value: true, percentage: 20 }, - { value: false, percentage: 50 } - ], i, 'jkl')) - - expect(distribution['true'] + distribution['false']).toEqual(n) - expect(distribution['true'] / n).toBeCloseTo(0.2, 1) - expect(distribution['false'] / n).toBeCloseTo(0.8, 1) - }) }) diff --git a/src/sessions.ts b/src/sessions.ts index 5bb5442..49d6cb8 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -1,4 +1,4 @@ -import murmur from 'murmurhash-js' +//import murmur from 'murmurhash-js' import { Rollout, Session } from './types' @@ -10,18 +10,80 @@ export function parseSession (namespace: string, id: string, value: string): Ses } } -export function resolveState (rollouts: Rollout[], timestamp: number, sessionId: string): boolean { +// export function resolveState_no_traits (rollouts: Rollout[], timestamp: number, sessionId: string): boolean { +// if (!rollouts || rollouts.length === 0) { +// return false +// } + +// const param = murmur.murmur3(`${sessionId}${timestamp}`) % 100 + +// const rollout = rollouts.find(r => +// r.percentage === undefined +// ? r.value +// : param <= r.percentage +// ) + +// return (rollout && rollout.value) || false +// } + +const hashCode = function(str:string) { + var hash = 0, + i, chr; + if (str.length === 0) return hash; + for (i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +export function resolveState (rollouts: Rollout[], timestamp: number, sessionId: string, sessionTraits: string[] = []): boolean { if (!rollouts || rollouts.length === 0) { return false } - const param = murmur.murmur3(`${sessionId}${timestamp}`) % 100 + const param = Math.abs(hashCode(`${sessionId}${timestamp}`)) % 100; + // the strategy for session traits to rollout traits matches: + // * both traits are string[], the match can be multi to multi. + // a match means all of the rollout traits are found in the session traits (i.e. give rollout traits ["a","b","c"], session traits ["a","b","c"] and ["a","b","c","d"] will match but session traits ["a","b"] do not) + // the more array member matches has higher precedence: + // (i.e. session traits ["a","b","c"], will match rollout traits ["a","b","c"] and ["a","b"], but rollout traits ["a","b","c"] will have higher precendence because it is more specific match + // if multiple Rollout traits match a session traits, {"value":false} take precedence (so you can ensure a feature matches the traits can be turned off) + // if multiple Rollout traits of {"value":false} match, the highest value of percentage takes precedence. + // {"value":true} Rollout traits with same level of precendence will only be considered if no false traits matches at this specificity + // if no traits matching rollout strategy found, then consider strategies do not have traits () - const rollout = rollouts.find(r => - r.percentage === undefined - ? r.value - : param <= r.percentage + // Examples: + // when the session has traits ["blue", "square", "plastic"], it will match rollout strategies that has traits: ["blue"], or ["blue","square"] or ["blue", "square", "plastic"], + // however, a rollout strategy traits:["blue", "square", "metal"] will NOT match this session + // so if you want both "blue" and "red" to be considered, they shall be 2 different rollout strategies, not listed as traits:["blue","red"], because this will only match sessions have traits:["blue","red"...] + let no_of_traits = -1; + let rollout_percentage = rollouts.reduce( + (pv, cro) => { + if ( cro.traits == null || cro.traits?.reduce((tpv, tcv) => {return tpv && sessionTraits.includes(tcv)}, (1==1)) ) { // if all traits of a Rollout are found in session traits + if ( (cro.traits?.length ?? 0) > no_of_traits ) { // if this Rollout strategy has more specific matching than previous ones use this percentage value (default is 100) + no_of_traits = (cro.traits?.length ?? 0); + if ( cro.value ) { + return (typeof cro.percentage === 'number' )? cro.percentage : 100 ; + } else { // if this is a "turn off" strategy, turn the percentage to a negative number, + return (typeof cro.percentage === 'number' )? -cro.percentage : -100 ; + } + } else if ((cro.traits?.length ?? 0 ) == no_of_traits ) { // if equal #_of_matching traits, false take precedence, then larger percentage value + if ( cro.value == false ) { + return Math.min( pv, (typeof cro.percentage === 'number' )? -cro.percentage : -100 ); + } else { + if (pv >= 0) { // if this is true, then only change the value if previous percentage is positive (meaning no false rules have applied yet at this specifity level) + return Math.max( pv, (typeof cro.percentage === 'number' )? cro.percentage : 100 ); + } + } + } + }; + return pv; + } + ,0 ) - - return (rollout && rollout.value) || false -} + + // default is false + return (param * Math.sign(1/rollout_percentage) < rollout_percentage ) || false +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b8fc446..b525cc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,9 @@ export interface Rollout { /** If defined, applies the given value for X% of the sessions */ percentage?: number + /** If defined, applies to only sessions match ALL the elements in this string[] */ + traits?: string[] + /** Value to be used */ value: FlagValue } diff --git a/test/saveFlag.test.ts b/test/saveFlag.test.ts index e7fd7dd..553cb60 100644 --- a/test/saveFlag.test.ts +++ b/test/saveFlag.test.ts @@ -3,7 +3,7 @@ import { newFlagClient, cleanUp, newTimestamp } from './util' import { namespaceKey } from '../src/keys' describe.only('save flag', () => { - afterEach(() => cleanUp()) + afterAll(() => cleanUp()) test('enabled flag', async () => { const [tog, redis] = newFlagClient() @@ -15,10 +15,24 @@ describe.only('save flag', () => { } await tog.saveFlag(flag) - const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black')) + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "") expect(saved.rollout).toMatchObject(flag.rollout) }) + test('delete flag', async () => { + const [tog, redis] = newFlagClient() + + const flag: Flag = { + namespace: 'foo', + name: 'black', + rollout: [{ value: true }] + } + await tog.deleteFlag(flag.namespace, flag.name) + + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "{}") + expect(saved.rollout).toBe(undefined) + }) + test('disabled flag', async () => { const [tog, redis] = newFlagClient() @@ -29,39 +43,39 @@ describe.only('save flag', () => { } await tog.saveFlag(flag) - const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black')) + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "") expect(saved.rollout).toMatchObject(flag.rollout) }) - test('flag with description', async () => { + test('update flag with description', async () => { const [tog, redis] = newFlagClient() const flag: Flag = { namespace: 'foo', name: 'black', timestamp: 123, - rollout: [{ value: true }], + rollout: [{ value: true , percentage: 90}], description: 'some description' } await tog.saveFlag(flag) - const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black')) + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "") expect(saved.description).toBe('some description') }) - test('flag with variants', async () => { + test('update flag with variants (trait)', async () => { const [tog, redis] = newFlagClient() const flag: Flag = { namespace: 'foo', name: 'black', rollout: [ - { value: true, percentage: 42 } + { value: true, percentage: 42 , traits: ["black","circle"]} ] } await tog.saveFlag(flag) - const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black')) + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "" ) expect(saved.rollout).toMatchObject(flag.rollout) }) @@ -72,11 +86,11 @@ describe.only('save flag', () => { namespace: 'foo', name: 'black', timestamp: 123, - rollout: [{ value: true }], + rollout: [{ value: true , percentage: 73, traits:["blue"]}], } await tog.saveFlag(flag) - const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black')) + const saved = JSON.parse(await redis.hget(namespaceKey('foo'), 'black') ?? "") expect(saved.timestamp).toBeCloseTo(newTimestamp()) }) }) diff --git a/test/session.test.ts b/test/session.test.ts index 5ee9cf2..452c661 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -4,8 +4,8 @@ import { newSessionClient, cleanUp, saveAllFlags, newFlagClient } from './util' import { SessionClient } from '../src' const fakeLogger = () => ({ - loggedMessage: undefined, - infoMessage: undefined, + loggedMessage: undefined, + infoMessage: undefined, error(message) { this.loggedMessage = message }, @@ -30,7 +30,7 @@ describe('session', () => { id: 'abc123', flags: {} }) - expect(logger.loggedMessage.toString()).toEqual('Error: timeout after 300ms') + expect(logger.loggedMessage?.toString()).toEqual('Error: timeout after 300ms') tog.redis.quit() tog.subscriber.quit() }) @@ -47,7 +47,7 @@ describe('session', () => { id: 'abc123', flags: {} }) - expect(logger.loggedMessage.toString()).toEqual('Error: Connection is closed.') + expect(logger.loggedMessage?.toString()).toEqual('Error: Connection is closed.') }) }) @@ -149,7 +149,7 @@ describe('session', () => { { namespace, timestamp: 2, name: 'white', rollout: [] } ]) - const session = await tog.session('foo', 'abc123', { flags: { blue: true } }) + const session = await tog.session('foo', 'abc123', [], { flags: { blue: true } }) expect(session).toEqual({ namespace: 'foo', id: 'abc123', @@ -166,7 +166,7 @@ describe('session', () => { { namespace, timestamp: 2, name: 'white', rollout: [] } ]) - const session = await tog.session('foo', 'abc123', { flags: { white: true } }) + const session = await tog.session('foo', 'abc123', [], { flags: { white: true } }) expect(session).toEqual({ namespace: 'foo', id: 'abc123',